diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index b3f7c67c51..007e4341b8 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -27,10 +27,10 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2021.608.0", + "version": "2021.725.0", "commands": [ "localisation" ] } } -} \ No newline at end of file +} diff --git a/.editorconfig b/.editorconfig index f4d7e08d08..3c4997c88d 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 @@ -190,3 +190,5 @@ dotnet_diagnostic.CA2225.severity = none # Banned APIs dotnet_diagnostic.RS0030.severity = error + +dotnet_diagnostic.OLOC001.license_header = // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.\n// See the LICENCE file in the repository root for full licence text. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0c6b80e97e..fc61573416 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ +github: ppy custom: https://osu.ppy.sh/home/support diff --git a/.github/ISSUE_TEMPLATE/01-bug-issues.md b/.github/ISSUE_TEMPLATE/01-bug-issues.md deleted file mode 100644 index 7026179259..0000000000 --- a/.github/ISSUE_TEMPLATE/01-bug-issues.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: Bug Report -about: Report a bug or crash to desktop ---- - - - - -**Describe the bug:** - -**Screenshots or videos showing encountered issue:** - -**osu!lazer version:** - -**Logs:** - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c62231e8e0..47a6a4c3d3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,12 +1,12 @@ blank_issues_enabled: false contact_links: - - name: Suggestions or feature request - url: https://github.com/ppy/osu/discussions/categories/ideas - about: Got something you think should change or be added? Search for or start a new discussion! - name: Help url: https://github.com/ppy/osu/discussions/categories/q-a about: osu! not working as you'd expect? Not sure it's a bug? Check the Q&A section! + - name: Suggestions or feature request + url: https://github.com/ppy/osu/discussions/categories/ideas + about: Got something you think should change or be added? Search for or start a new discussion! - name: osu!stable issues url: https://github.com/ppy/osu-stable-issues - about: For osu!stable bugs (not osu!lazer), check out the dedicated repository. Note that we only accept serious bug reports. + about: For osu!(stable) - ie. the current "live" game version, check out the dedicated repository. Note that this is for serious bug reports only, not tech support. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29cbdd2d37..0da1f9636b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,9 +79,14 @@ jobs: 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 + exit_code=0 + while read -r line; do + if [[ ! -z "$line" ]]; then + echo "::error::$line" + exit_code=1 + fi + done <<< $(dotnet codefilesanity) + exit $exit_code # Temporarily disabled due to test failures, but it won't work anyway until the tool is upgraded. # - name: .NET Format (Dry Run) diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml new file mode 100644 index 0000000000..9e11ab6663 --- /dev/null +++ b/.github/workflows/diffcalc.yml @@ -0,0 +1,206 @@ +# Listens for new PR comments containing !pp check [id], and runs a diffcalc comparison against master. +# Usage: +# !pp check 0 | Runs only the osu! ruleset. +# !pp check 0 2 | Runs only the osu! and catch rulesets. +# + +name: Difficulty Calculation +on: + issue_comment: + types: [ created ] + +env: + CONCURRENCY: 4 + ALLOW_DOWNLOAD: 1 + SAVE_DOWNLOADED: 1 + SKIP_INSERT_ATTRIBUTES: 1 + +jobs: + metadata: + name: Check for requests + runs-on: self-hosted + if: github.event.issue.pull_request && contains(github.event.comment.body, '!pp check') && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') + outputs: + matrix: ${{ steps.generate-matrix.outputs.matrix }} + continue: ${{ steps.generate-matrix.outputs.continue }} + steps: + - name: Construct build matrix + id: generate-matrix + run: | + if [[ "${{ github.event.comment.body }}" =~ "osu" ]] ; then + MATRIX_PROJECTS_JSON+='{ "name": "osu", "id": 0 },' + fi + if [[ "${{ github.event.comment.body }}" =~ "taiko" ]] ; then + MATRIX_PROJECTS_JSON+='{ "name": "taiko", "id": 1 },' + fi + if [[ "${{ github.event.comment.body }}" =~ "catch" ]] ; then + MATRIX_PROJECTS_JSON+='{ "name": "catch", "id": 2 },' + fi + if [[ "${{ github.event.comment.body }}" =~ "mania" ]] ; then + MATRIX_PROJECTS_JSON+='{ "name": "mania", "id": 3 },' + fi + + if [[ "${MATRIX_PROJECTS_JSON}" != "" ]]; then + MATRIX_JSON="{ \"ruleset\": [ ${MATRIX_PROJECTS_JSON} ] }" + echo "${MATRIX_JSON}" + CONTINUE="yes" + else + CONTINUE="no" + fi + + echo "::set-output name=continue::${CONTINUE}" + echo "::set-output name=matrix::${MATRIX_JSON}" + diffcalc: + name: Run + runs-on: self-hosted + timeout-minutes: 1440 + if: needs.metadata.outputs.continue == 'yes' + needs: metadata + strategy: + matrix: ${{ fromJson(needs.metadata.outputs.matrix) }} + steps: + - name: Verify MySQL connection from host + run: | + mysql -e "SHOW DATABASES" + + - name: Drop previous databases + run: | + for db in osu_master osu_pr + do + mysql -e "DROP DATABASE IF EXISTS $db" + done + + - name: Create directory structure + run: | + mkdir -p $GITHUB_WORKSPACE/master/ + mkdir -p $GITHUB_WORKSPACE/pr/ + + - name: Get upstream branch # https://akaimo.hatenablog.jp/entry/2020/05/16/101251 + id: upstreambranch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "::set-output name=branchname::$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" + echo "::set-output name=repo::$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" + + # Checkout osu + - name: Checkout osu (master) + uses: actions/checkout@v2 + with: + path: 'master/osu' + - name: Checkout osu (pr) + uses: actions/checkout@v2 + with: + path: 'pr/osu' + repository: ${{ steps.upstreambranch.outputs.repo }} + ref: ${{ steps.upstreambranch.outputs.branchname }} + + - name: Checkout osu-difficulty-calculator (master) + uses: actions/checkout@v2 + with: + repository: ppy/osu-difficulty-calculator + path: 'master/osu-difficulty-calculator' + - name: Checkout osu-difficulty-calculator (pr) + uses: actions/checkout@v2 + with: + repository: ppy/osu-difficulty-calculator + path: 'pr/osu-difficulty-calculator' + + - name: Install .NET 5.0.x + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "5.0.x" + + # Sanity checks to make sure diffcalc is not run when incompatible. + - name: Build diffcalc (master) + run: | + cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator + ./UseLocalOsu.sh + dotnet build + - name: Build diffcalc (pr) + run: | + cd $GITHUB_WORKSPACE/pr/osu-difficulty-calculator + ./UseLocalOsu.sh + dotnet build + + - name: Download + import data + run: | + PERFORMANCE_DATA_NAME=$(curl https://data.ppy.sh/ | grep performance_${{ matrix.ruleset.name }}_top_1000 | tail -1 | awk -F "\"" '{print $2}' | sed 's/\.tar\.bz2//g') + BEATMAPS_DATA_NAME=$(curl https://data.ppy.sh/ | grep osu_files | tail -1 | awk -F "\"" '{print $2}' | sed 's/\.tar\.bz2//g') + + # Set env variable for further steps. + echo "BEATMAPS_PATH=$GITHUB_WORKSPACE/$BEATMAPS_DATA_NAME" >> $GITHUB_ENV + + cd $GITHUB_WORKSPACE + + echo "Downloading database dump $PERFORMANCE_DATA_NAME.." + wget -q -nc https://data.ppy.sh/$PERFORMANCE_DATA_NAME.tar.bz2 + echo "Extracting.." + tar -xf $PERFORMANCE_DATA_NAME.tar.bz2 + + echo "Downloading beatmap dump $BEATMAPS_DATA_NAME.." + wget -q -nc https://data.ppy.sh/$BEATMAPS_DATA_NAME.tar.bz2 + echo "Extracting.." + tar -xf $BEATMAPS_DATA_NAME.tar.bz2 + + cd $PERFORMANCE_DATA_NAME + + for db in osu_master osu_pr + do + echo "Setting up database $db.." + + mysql -e "CREATE DATABASE $db" + + echo "Importing beatmaps.." + cat osu_beatmaps.sql | mysql $db + echo "Importing beatmapsets.." + cat osu_beatmapsets.sql | mysql $db + + echo "Creating table structure.." + mysql $db -e 'CREATE TABLE `osu_beatmap_difficulty` ( + `beatmap_id` int unsigned NOT NULL, + `mode` tinyint NOT NULL DEFAULT 0, + `mods` int unsigned NOT NULL, + `diff_unified` float NOT NULL, + `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`beatmap_id`,`mode`,`mods`), + KEY `diff_sort` (`mode`,`mods`,`diff_unified`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;' + done + + - name: Run diffcalc (master) + env: + DB_NAME: osu_master + run: | + cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator/osu.Server.DifficultyCalculator + dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }} + - name: Run diffcalc (pr) + env: + DB_NAME: osu_pr + run: | + cd $GITHUB_WORKSPACE/pr/osu-difficulty-calculator/osu.Server.DifficultyCalculator + dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }} + + - name: Print diffs + run: | + mysql -e " + SELECT + m.beatmap_id, + m.mods, + b.filename, + m.diff_unified as 'sr_master', + p.diff_unified as 'sr_pr', + (p.diff_unified - m.diff_unified) as 'diff' + FROM osu_master.osu_beatmap_difficulty m + JOIN osu_pr.osu_beatmap_difficulty p + ON m.beatmap_id = p.beatmap_id + AND m.mode = p.mode + AND m.mods = p.mods + JOIN osu_pr.osu_beatmaps b + ON b.beatmap_id = p.beatmap_id + WHERE abs(m.diff_unified - p.diff_unified) > 0.1 + ORDER BY abs(m.diff_unified - p.diff_unified) + DESC + LIMIT 10000;" + + # Todo: Run ppcalc diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml index 8fa7608b8e..498a710df9 100644 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml @@ -1,8 +1,8 @@ - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/indexLayout.xml b/.idea/.idea.osu/.idea/indexLayout.xml index 27ba142e96..7b08163ceb 100644 --- a/.idea/.idea.osu/.idea/indexLayout.xml +++ b/.idea/.idea.osu/.idea/indexLayout.xml @@ -1,6 +1,6 @@ - + diff --git a/.run/Dual client test.run.xml b/.run/Dual client test.run.xml new file mode 100644 index 0000000000..e112aa3d5d --- /dev/null +++ b/.run/Dual client test.run.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.run/osu! (Second Client).run.xml b/.run/osu! (Second Client).run.xml new file mode 100644 index 0000000000..599b4b986b --- /dev/null +++ b/.run/osu! (Second Client).run.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file 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/README.md b/README.md index 2213b42121..786ce2589d 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ # osu! -[![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu) +[![Build status](https://github.com/ppy/osu/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/ppy/osu/actions/workflows/ci.yml) [![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)](https://github.com/ppy/osu/releases/latest) [![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) [![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) 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! @@ -31,12 +31,11 @@ If you are looking to install or test osu! without setting up a development envi **Latest build:** -| [Windows (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.12+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS(iOS 10+)](https://osu.ppy.sh/home/testflight) | [Android (5+)](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) +| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.12+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) | ------------- | ------------- | ------------- | ------------- | ------------- | - The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets. -- When running on Windows 7 or 8.1, *[additional prerequisites](https://docs.microsoft.com/en-us/dotnet/core/install/windows?tabs=net50&pivots=os-windows#dependencies)** may be required to correctly run .NET 5 applications if your operating system is not up-to-date with the latest service packs. If your platform is not listed above, there is still a chance you can manually build it by following the instructions below. ## Developing a custom ruleset diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs index 9c512a01ea..536fdfc6df 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs @@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.EmptyFreeform.Tests [BackgroundDependencyLoader] private void load(GameHost host, OsuGameBase gameBase) { - OsuGame game = new OsuGame(); - game.SetHost(host); - Children = new Drawable[] { new Box @@ -25,8 +22,9 @@ namespace osu.Game.Rulesets.EmptyFreeform.Tests RelativeSizeAxes = Axes.Both, Colour = Color4.Black, }, - game }; + + AddGame(new OsuGame()); } } } 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 3dd6be7307..e28053d0ca 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 @@ -10,7 +10,7 @@ - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs index 270d906b01..3cdf44e6f1 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs @@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.Pippidon.Tests [BackgroundDependencyLoader] private void load(GameHost host, OsuGameBase gameBase) { - OsuGame game = new OsuGame(); - game.SetHost(host); - Children = new Drawable[] { new Box @@ -25,8 +22,9 @@ namespace osu.Game.Rulesets.Pippidon.Tests RelativeSizeAxes = Axes.Both, Colour = Color4.Black, }, - game }; + + AddGame(new OsuGame()); } } } 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 0c4bfe0ed7..027bd0b7e2 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 @@ -10,7 +10,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs index aed6abb6bf..4d3f5086d9 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs @@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.EmptyScrolling.Tests [BackgroundDependencyLoader] private void load(GameHost host, OsuGameBase gameBase) { - OsuGame game = new OsuGame(); - game.SetHost(host); - Children = new Drawable[] { new Box @@ -25,8 +22,9 @@ namespace osu.Game.Rulesets.EmptyScrolling.Tests RelativeSizeAxes = Axes.Both, Colour = Color4.Black, }, - game }; + + AddGame(new OsuGame()); } } } 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 bb0a487274..e2c715d385 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 @@ -10,7 +10,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs index 270d906b01..3cdf44e6f1 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs @@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.Pippidon.Tests [BackgroundDependencyLoader] private void load(GameHost host, OsuGameBase gameBase) { - OsuGame game = new OsuGame(); - game.SetHost(host); - Children = new Drawable[] { new Box @@ -25,8 +22,9 @@ namespace osu.Game.Rulesets.Pippidon.Tests RelativeSizeAxes = Axes.Both, Colour = Color4.Black, }, - game }; + + AddGame(new OsuGame()); } } } 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 0c4bfe0ed7..027bd0b7e2 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 @@ -10,7 +10,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs index dd0a20f1b4..98dba622d0 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; using osuTK; @@ -61,9 +62,9 @@ namespace osu.Game.Rulesets.Pippidon.UI } } - public bool OnPressed(PippidonAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case PippidonAction.MoveUp: changeLane(-1); @@ -78,7 +79,7 @@ namespace osu.Game.Rulesets.Pippidon.UI } } - public void OnReleased(PippidonAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Android.props b/osu.Android.props index 9067ae1cd9..db62667fc2 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,11 +51,11 @@ - - + + - + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 063e02d349..0bcbfc4baf 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -20,8 +20,21 @@ 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.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.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")] + [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")] + [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-replay")] + [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", + // newer official mime types (see https://osu.ppy.sh/wiki/en/osu%21_File_Formats). + "application/x-osu-beatmap-archive", + "application/x-osu-skin-archive", + "application/x-osu-replay", + })] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault }, DataSchemes = new[] { "osu", "osump" })] public class OsuGameActivity : AndroidGameActivity { @@ -66,12 +79,14 @@ namespace osu.Android case Intent.ActionSendMultiple: { var uris = new List(); + for (int i = 0; i < intent.ClipData?.ItemCount; i++) { var content = intent.ClipData?.GetItemAt(i); if (content != null) uris.Add(content.Uri); } + handleImportFromUris(uris.ToArray()); break; } diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index 582c856a47..b2599535ae 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -64,7 +64,7 @@ - + \ No newline at end of file diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index 832d26b0ef..e2b40e9dc6 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.cs @@ -139,11 +139,11 @@ namespace osu.Desktop { switch (activity) { - case UserActivity.SoloGame solo: - return solo.Beatmap.ToString(); + case UserActivity.InGame game: + return game.BeatmapInfo.ToString(); case UserActivity.Editing edit: - return edit.Beatmap.ToString(); + return edit.BeatmapInfo.ToString(); case UserActivity.InLobby lobby: return privacyMode.Value == DiscordRichPresenceMode.Limited ? string.Empty : lobby.Room.Name.Value; diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 5fb09c0cef..8ccd23b418 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -3,7 +3,6 @@ using System; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework; @@ -17,13 +16,43 @@ namespace osu.Desktop { public static class Program { + private const string base_game_name = @"osu"; + [STAThread] public static int Main(string[] args) { // Back up the cwd before DesktopGameHost changes it var cwd = Environment.CurrentDirectory; - using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) + string gameName = base_game_name; + bool tournamentClient = false; + + foreach (var arg in args) + { + var split = arg.Split('='); + + var key = split[0]; + var val = split.Length > 1 ? split[1] : string.Empty; + + switch (key) + { + case "--tournament": + tournamentClient = true; + break; + + case "--debug-client-id": + if (!DebugUtils.IsDebugBuild) + throw new InvalidOperationException("Cannot use this argument in a non-debug build."); + + if (!int.TryParse(val, out int clientID)) + throw new ArgumentException("Provided client ID must be an integer."); + + gameName = $"{base_game_name}-{clientID}"; + break; + } + } + + using (DesktopGameHost host = Host.GetSuitableHost(gameName, true)) { host.ExceptionThrown += handleException; @@ -45,19 +74,16 @@ namespace osu.Desktop // we want to allow multiple instances to be started when in debug. if (!DebugUtils.IsDebugBuild) + { + Logger.Log(@"osu! does not support multiple running instances.", LoggingTarget.Runtime, LogLevel.Error); return 0; + } } - switch (args.FirstOrDefault() ?? string.Empty) - { - default: - host.Run(new OsuGameDesktop(args)); - break; - - case "--tournament": - host.Run(new TournamentGame()); - break; - } + if (tournamentClient) + host.Run(new TournamentGame()); + else + host.Run(new OsuGameDesktop(args)); return 0; } diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 58d67c11d9..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,6 +111,7 @@ 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 { diff --git a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs index efc3f21149..dbfd170ea1 100644 --- a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs +++ b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs @@ -5,23 +5,23 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Platform; -using osu.Game; using osu.Game.Configuration; +using osu.Game.Screens.Play; namespace osu.Desktop.Windows { public class GameplayWinKeyBlocker : Component { private Bindable disableWinKey; - private Bindable localUserPlaying; + private IBindable localUserPlaying; [Resolved] private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(OsuGame game, OsuConfigManager config) + private void load(ILocalUserPlayInfo localUserInfo, OsuConfigManager config) { - localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); + localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy(); localUserPlaying.BindValueChanged(_ => updateBlocking()); disableWinKey = config.GetBindable(OsuSetting.GameplayDisableWinKey); diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index 53a4e5edf5..89b9ffb94b 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -6,7 +6,7 @@ A free-to-win rhythm game. Rhythm is just a *click* away! osu! osu! - osu! + osu!(lazer) lazer.ico app.manifest 0.0.0 diff --git a/osu.Game.Benchmarks/BenchmarkMod.cs b/osu.Game.Benchmarks/BenchmarkMod.cs new file mode 100644 index 0000000000..c5375e9f09 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkMod.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 BenchmarkDotNet.Attributes; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkMod : BenchmarkTest + { + private OsuModDoubleTime mod; + + [Params(1, 10, 100)] + public int Times { get; set; } + + public override void SetUp() + { + base.SetUp(); + mod = new OsuModDoubleTime(); + } + + [Benchmark] + public int ModHashCode() + { + var hashCode = new HashCode(); + + for (int i = 0; i < Times; i++) + hashCode.Add(mod); + + return hashCode.ToHashCode(); + } + } +} diff --git a/osu.Game.Benchmarks/BenchmarkRuleset.cs b/osu.Game.Benchmarks/BenchmarkRuleset.cs new file mode 100644 index 0000000000..2835ec9499 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkRuleset.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; +using osu.Game.Online.API; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkRuleset : BenchmarkTest + { + private OsuRuleset ruleset; + private APIMod apiModDoubleTime; + private APIMod apiModDifficultyAdjust; + + public override void SetUp() + { + base.SetUp(); + ruleset = new OsuRuleset(); + apiModDoubleTime = new APIMod { Acronym = "DT" }; + apiModDifficultyAdjust = new APIMod { Acronym = "DA" }; + } + + [Benchmark] + public void BenchmarkToModDoubleTime() + { + apiModDoubleTime.ToMod(ruleset); + } + + [Benchmark] + public void BenchmarkToModDifficultyAdjust() + { + apiModDifficultyAdjust.ToMod(ruleset); + } + + [Benchmark] + public void BenchmarkGetAllMods() + { + ruleset.CreateAllMods().Consume(new Consumer()); + } + + [Benchmark] + public void BenchmarkGetAllModsForReference() + { + ruleset.AllMods.Consume(new Consumer()); + } + + [Benchmark] + public void BenchmarkGetForAcronym() + { + ruleset.CreateModFromAcronym("DT"); + } + + [Benchmark] + public void BenchmarkGetForType() + { + ruleset.CreateMod(); + } + } +} diff --git a/osu.Game.Benchmarks/Program.cs b/osu.Game.Benchmarks/Program.cs index c55075fea6..439ced53ab 100644 --- a/osu.Game.Benchmarks/Program.cs +++ b/osu.Game.Benchmarks/Program.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 BenchmarkDotNet.Configs; using BenchmarkDotNet.Running; namespace osu.Game.Benchmarks @@ -11,7 +12,7 @@ namespace osu.Game.Benchmarks { BenchmarkSwitcher .FromAssembly(typeof(Program).Assembly) - .Run(args); + .Run(args, DefaultConfig.Instance.WithOption(ConfigOptions.DisableOptimizationsValidator, true)); } } } diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index da8a0540f4..03f39f226c 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -7,7 +7,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index 5580358f89..2fab47f857 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -14,11 +14,11 @@ namespace osu.Game.Rulesets.Catch.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch"; - [TestCase(4.050601681491468d, "diffcalc-test")] + [TestCase(4.0505463516206195d, "diffcalc-test")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(5.169743871843191d, "diffcalc-test")] + [TestCase(5.1696411260785498d, "diffcalc-test")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new CatchModDoubleTime()); 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..d4c2c0f0af --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public abstract class CatchSelectionBlueprintTestScene : SelectionBlueprintTestScene + { + protected ScrollingHitObjectContainer HitObjectContainer => contentContainer.Playfield.HitObjectContainer; + + protected override Container Content => contentContainer; + + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + protected readonly EditorBeatmap EditorBeatmap; + + private readonly CatchEditorTestSceneContainer contentContainer; + + protected CatchSelectionBlueprintTestScene() + { + EditorBeatmap = new EditorBeatmap(new CatchBeatmap()) { Difficulty = { CircleSize = 0 } }; + EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint + { + BeatLength = 100 + }); + + base.Content.Add(new EditorBeatmapDependencyContainer(EditorBeatmap, new BindableBeatDivisor()) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + EditorBeatmap, + contentContainer = new CatchEditorTestSceneContainer() + }, + }); + } + + protected void AddMouseMoveStep(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); + }); + + private class EditorBeatmapDependencyContainer : Container + { + [Cached] + private readonly EditorClock editorClock; + + [Cached] + private readonly BindableBeatDivisor beatDivisor; + + public EditorBeatmapDependencyContainer(IBeatmap beatmap, BindableBeatDivisor beatDivisor) + { + editorClock = new EditorClock(beatmap, beatDivisor); + this.beatDivisor = beatDivisor; + } + } + } +} 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..cca3701a60 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.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(), new BeatmapDifficulty()); + + 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/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/TestSceneJuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs new file mode 100644 index 0000000000..981efc9a13 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs @@ -0,0 +1,156 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +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 TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene + { + private const double velocity = 0.5; + + private JuiceStream lastObject => LastObject?.HitObject as JuiceStream; + + protected override IBeatmap GetPlayableBeatmap() + { + var playable = base.GetPlayableBeatmap(); + playable.Difficulty.SliderTickRate = 5; + playable.Difficulty.SliderMultiplier = velocity * 10; + return playable; + } + + [Test] + public void TestBasicPlacement() + { + double[] times = { 300, 800 }; + float[] positions = { 100, 200 }; + addPlacementSteps(times, positions); + + AddAssert("juice stream is placed", () => lastObject != null); + AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0])); + AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1])); + AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0])); + AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1])); + } + + [Test] + public void TestEmptyNotCommitted() + { + addMoveAndClickSteps(100, 100); + addMoveAndClickSteps(100, 100); + addMoveAndClickSteps(100, 100, true); + AddAssert("juice stream not placed", () => lastObject == null); + } + + [Test] + public void TestMultipleSegments() + { + double[] times = { 100, 300, 500, 700 }; + float[] positions = { 100, 150, 100, 100 }; + addPlacementSteps(times, positions); + + AddAssert("has 4 vertices", () => lastObject.Path.ControlPoints.Count == 4); + addPathCheckStep(times, positions); + } + + [Test] + public void TestVelocityLimit() + { + double[] times = { 100, 300 }; + float[] positions = { 200, 500 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 200, 300 }); + } + + [Test] + public void TestPreviousVerticesAreFixed() + { + double[] times = { 100, 300, 500, 700 }; + float[] positions = { 200, 400, 100, 500 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 200, 300, 200, 300 }); + } + + [Test] + public void TestClampedPositionIsRestored() + { + double[] times = { 100, 300, 500 }; + float[] positions = { 200, 200, 0, 250 }; + + addMoveAndClickSteps(times[0], positions[0]); + addMoveAndClickSteps(times[1], positions[1]); + AddMoveStep(times[2], positions[2]); + addMoveAndClickSteps(times[2], positions[3], true); + + addPathCheckStep(times, new float[] { 200, 200, 250 }); + } + + [Test] + public void TestFirstVertexIsFixed() + { + double[] times = { 100, 200 }; + float[] positions = { 100, 300 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 100, 150 }); + } + + [Test] + public void TestOutOfOrder() + { + double[] times = { 100, 700, 500, 300 }; + float[] positions = { 100, 200, 150, 50 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, positions); + } + + [Test] + public void TestMoveBeforeFirstVertex() + { + double[] times = { 300, 500, 100 }; + float[] positions = { 100, 100, 100 }; + addPlacementSteps(times, positions); + AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0])); + AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1], 1e-3)); + } + + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableJuiceStream((JuiceStream)hitObject); + + protected override PlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint(); + + private void addMoveAndClickSteps(double time, float position, bool end = false) + { + AddMoveStep(time, position); + AddClickStep(end ? MouseButton.Right : MouseButton.Left); + } + + private void addPlacementSteps(double[] times, float[] positions) + { + for (int i = 0; i < times.Length; i++) + addMoveAndClickSteps(times[i], positions[i], i == times.Length - 1); + } + + private void addPathCheckStep(double[] times, float[] positions) => AddStep("assert path is correct", () => + Assert.That(getPositions(times), Is.EqualTo(positions).Within(Precision.FLOAT_EPSILON))); + + private float[] getPositions(IEnumerable times) + { + JuiceStream hitObject = lastObject.AsNonNull(); + return times + .Select(time => (time - hitObject.StartTime) * hitObject.Velocity) + .Select(distance => hitObject.EffectiveX + hitObject.Path.PositionAt(distance / hitObject.Distance).X) + .ToArray(); + } + } +} 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..155d033dd0 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs @@ -0,0 +1,286 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; +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.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene + { + private JuiceStream hitObject; + + private readonly ManualClock manualClock = new ManualClock(); + + [SetUp] + public void SetUp() => Schedule(() => + { + EditorBeatmap.Clear(); + Content.Clear(); + + manualClock.CurrentTime = 0; + Content.Clock = new FramedClock(manualClock); + + InputManager.ReleaseButton(MouseButton.Left); + InputManager.ReleaseKey(Key.ShiftLeft); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + [Test] + public void TestBasicComponentLayout() + { + double[] times = { 100, 300, 500 }; + float[] positions = { 100, 200, 100 }; + addBlueprintStep(times, positions); + + for (int i = 0; i < times.Length; i++) + addVertexCheckStep(times.Length, i, times[i], positions[i]); + + AddAssert("correct outline count", () => + { + var expected = hitObject.NestedHitObjects.Count(h => !(h is TinyDroplet)); + return this.ChildrenOfType().Count() == expected; + }); + AddAssert("correct vertex piece count", () => + this.ChildrenOfType().Count() == times.Length); + + AddAssert("first vertex is semitransparent", () => + Precision.DefinitelyBigger(1, this.ChildrenOfType().First().Alpha)); + } + + [Test] + public void TestVertexDrag() + { + double[] times = { 100, 400, 700 }; + float[] positions = { 100, 100, 100 }; + addBlueprintStep(times, positions); + + addDragStartStep(times[1], positions[1]); + + AddMouseMoveStep(500, 150); + addVertexCheckStep(3, 1, 500, 150); + + addDragEndStep(); + addDragStartStep(times[2], positions[2]); + + AddMouseMoveStep(300, 50); + addVertexCheckStep(3, 1, 300, 50); + addVertexCheckStep(3, 2, 500, 150); + + AddMouseMoveStep(-100, 100); + addVertexCheckStep(3, 1, times[0], positions[0]); + } + + [Test] + public void TestMultipleDrag() + { + double[] times = { 100, 300, 500, 700 }; + float[] positions = { 100, 100, 100, 100 }; + addBlueprintStep(times, positions); + + AddMouseMoveStep(times[1], positions[1]); + AddStep("press left", () => InputManager.PressButton(MouseButton.Left)); + AddStep("release left", () => InputManager.ReleaseButton(MouseButton.Left)); + AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft)); + addDragStartStep(times[2], positions[2]); + + AddMouseMoveStep(times[2] - 50, positions[2] - 50); + addVertexCheckStep(4, 1, times[1] - 50, positions[1] - 50); + addVertexCheckStep(4, 2, times[2] - 50, positions[2] - 50); + } + + [Test] + public void TestClampedPositionIsRestored() + { + const double velocity = 0.25; + double[] times = { 100, 500, 700 }; + float[] positions = { 100, 100, 100 }; + addBlueprintStep(times, positions, velocity); + + addDragStartStep(times[1], positions[1]); + + AddMouseMoveStep(times[1], 200); + addVertexCheckStep(3, 1, times[1], 200); + addVertexCheckStep(3, 2, times[2], 150); + + AddMouseMoveStep(times[1], 100); + addVertexCheckStep(3, 1, times[1], 100); + // Stored position is restored. + addVertexCheckStep(3, 2, times[2], positions[2]); + + AddMouseMoveStep(times[1], 300); + addDragEndStep(); + addDragStartStep(times[1], 300); + + AddMouseMoveStep(times[1], 100); + // Position is different because a changed position is committed when the previous drag is ended. + addVertexCheckStep(3, 2, times[2], 250); + } + + [Test] + public void TestScrollWhileDrag() + { + double[] times = { 300, 500 }; + float[] positions = { 100, 100 }; + addBlueprintStep(times, positions); + + addDragStartStep(times[1], positions[1]); + // This mouse move is necessary to start drag and capture the input. + AddMouseMoveStep(times[1], positions[1] + 50); + + AddStep("scroll playfield", () => manualClock.CurrentTime += 200); + AddMouseMoveStep(times[1] + 200, positions[1] + 100); + addVertexCheckStep(2, 1, times[1] + 200, positions[1] + 100); + } + + [Test] + public void TestUpdateFromHitObject() + { + double[] times = { 100, 300 }; + float[] positions = { 200, 200 }; + addBlueprintStep(times, positions); + + AddStep("update hit object path", () => + { + hitObject.Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(100, 100), + new Vector2(0, 200), + }); + EditorBeatmap.Update(hitObject); + }); + AddAssert("path is updated", () => getVertices().Count > 2); + } + + [Test] + public void TestAddVertex() + { + double[] times = { 100, 700 }; + float[] positions = { 200, 200 }; + addBlueprintStep(times, positions, 0.2); + + addAddVertexSteps(500, 150); + addVertexCheckStep(3, 1, 500, 150); + + addAddVertexSteps(90, 220); + addVertexCheckStep(4, 1, times[0], positions[0]); + + addAddVertexSteps(750, 180); + addVertexCheckStep(5, 4, 750, 180); + AddAssert("duration is changed", () => Precision.AlmostEquals(hitObject.Duration, 800 - times[0], 1e-3)); + } + + [Test] + public void TestDeleteVertex() + { + double[] times = { 100, 300, 500 }; + float[] positions = { 100, 200, 150 }; + addBlueprintStep(times, positions); + + addDeleteVertexSteps(times[1], positions[1]); + addVertexCheckStep(2, 1, times[2], positions[2]); + + // The first vertex cannot be deleted. + addDeleteVertexSteps(times[0], positions[0]); + addVertexCheckStep(2, 0, times[0], positions[0]); + + addDeleteVertexSteps(times[2], positions[2]); + addVertexCheckStep(1, 0, times[0], positions[0]); + } + + [Test] + public void TestVertexResampling() + { + addBlueprintStep(100, 100, new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(100, 100), + new Vector2(50, 200), + }), 0.5); + AddAssert("1 vertex per 1 nested HO", () => getVertices().Count == hitObject.NestedHitObjects.Count); + AddAssert("slider path not yet changed", () => hitObject.Path.ControlPoints[0].Type == PathType.PerfectCurve); + addAddVertexSteps(150, 150); + AddAssert("slider path change to linear", () => hitObject.Path.ControlPoints[0].Type == PathType.Linear); + } + + private void addBlueprintStep(double time, float x, SliderPath sliderPath, double velocity) => AddStep("add selection blueprint", () => + { + hitObject = new JuiceStream + { + StartTime = time, + X = x, + Path = sliderPath, + }; + EditorBeatmap.Difficulty.SliderMultiplier = velocity; + EditorBeatmap.Add(hitObject); + EditorBeatmap.Update(hitObject); + Assert.That(hitObject.Velocity, Is.EqualTo(velocity)); + AddBlueprint(new JuiceStreamSelectionBlueprint(hitObject)); + }); + + private void addBlueprintStep(double[] times, float[] positions, double velocity = 0.5) + { + var path = new JuiceStreamPath(); + for (int i = 1; i < times.Length; i++) + path.Add((times[i] - times[0]) * velocity, positions[i] - positions[0]); + + var sliderPath = new SliderPath(); + path.ConvertToSliderPath(sliderPath, 0); + addBlueprintStep(times[0], positions[0], sliderPath, velocity); + } + + private IReadOnlyList getVertices() => this.ChildrenOfType().Single().Vertices; + + private void addVertexCheckStep(int count, int index, double time, float x) => AddAssert($"vertex {index} of {count} at {time}, {x}", () => + { + double expectedDistance = (time - hitObject.StartTime) * hitObject.Velocity; + float expectedX = x - hitObject.OriginalX; + var vertices = getVertices(); + return vertices.Count == count && + Precision.AlmostEquals(vertices[index].Distance, expectedDistance, 1e-3) && + Precision.AlmostEquals(vertices[index].X, expectedX); + }); + + private void addDragStartStep(double time, float x) + { + AddMouseMoveStep(time, x); + AddStep("start dragging", () => InputManager.PressButton(MouseButton.Left)); + } + + private void addDragEndStep() => AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left)); + + private void addAddVertexSteps(double time, float x) + { + AddMouseMoveStep(time, x); + AddStep("add vertex", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + } + + private void addDeleteVertexSteps(double time, float x) + { + AddMouseMoveStep(time, x); + AddStep("delete vertex", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs new file mode 100644 index 0000000000..8fa96fb8c9 --- /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 = 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.X, Is.EqualTo(path.Vertices[0].X)); + assertInvariants(path.Vertices, true); + + foreach (var point in sliderPath.ControlPoints) + { + Assert.That(point.Type, Is.EqualTo(PathType.Linear).Or.Null); + Assert.That(sliderStartY + point.Position.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/Mods/CatchModMirrorTest.cs b/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.cs new file mode 100644 index 0000000000..fbbfee6b60 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Mods/CatchModMirrorTest.cs @@ -0,0 +1,120 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests.Mods +{ + [TestFixture] + public class CatchModMirrorTest + { + [Test] + public void TestModMirror() + { + IBeatmap original = createBeatmap(false); + IBeatmap mirrored = createBeatmap(true); + + assertEffectivePositionsMirrored(original, mirrored); + } + + private static IBeatmap createBeatmap(bool withMirrorMod) + { + var beatmap = createRawBeatmap(); + var mirrorMod = new CatchModMirror(); + + var beatmapProcessor = new CatchBeatmapProcessor(beatmap); + beatmapProcessor.PreProcess(); + + foreach (var hitObject in beatmap.HitObjects) + hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + beatmapProcessor.PostProcess(); + + if (withMirrorMod) + mirrorMod.ApplyToBeatmap(beatmap); + + return beatmap; + } + + private static IBeatmap createRawBeatmap() => new Beatmap + { + HitObjects = new List + { + new Fruit + { + OriginalX = 150, + StartTime = 0 + }, + new Fruit + { + OriginalX = 450, + StartTime = 500 + }, + new JuiceStream + { + OriginalX = 250, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(new Vector2(-100, 1)), + new PathControlPoint(new Vector2(0, 2)), + new PathControlPoint(new Vector2(100, 3)), + new PathControlPoint(new Vector2(0, 4)) + } + }, + StartTime = 1000, + }, + new BananaShower + { + StartTime = 5000, + Duration = 5000 + } + } + }; + + private static void assertEffectivePositionsMirrored(IBeatmap original, IBeatmap mirrored) + { + if (original.HitObjects.Count != mirrored.HitObjects.Count) + Assert.Fail($"Top-level object count mismatch (original: {original.HitObjects.Count}, mirrored: {mirrored.HitObjects.Count})"); + + for (int i = 0; i < original.HitObjects.Count; ++i) + { + var originalObject = (CatchHitObject)original.HitObjects[i]; + var mirroredObject = (CatchHitObject)mirrored.HitObjects[i]; + + // banana showers themselves are exempt, as we only really care about their nested bananas' positions. + if (!effectivePositionMirrored(originalObject, mirroredObject) && !(originalObject is BananaShower)) + Assert.Fail($"{originalObject.GetType().Name} at time {originalObject.StartTime} is not mirrored ({printEffectivePositions(originalObject, mirroredObject)})"); + + if (originalObject.NestedHitObjects.Count != mirroredObject.NestedHitObjects.Count) + Assert.Fail($"{originalObject.GetType().Name} nested object count mismatch (original: {originalObject.NestedHitObjects.Count}, mirrored: {mirroredObject.NestedHitObjects.Count})"); + + for (int j = 0; j < originalObject.NestedHitObjects.Count; ++j) + { + var originalNested = (CatchHitObject)originalObject.NestedHitObjects[j]; + var mirroredNested = (CatchHitObject)mirroredObject.NestedHitObjects[j]; + + if (!effectivePositionMirrored(originalNested, mirroredNested)) + Assert.Fail($"{originalObject.GetType().Name}'s nested {originalNested.GetType().Name} at time {originalObject.StartTime} is not mirrored ({printEffectivePositions(originalNested, mirroredNested)})"); + } + } + } + + private static string printEffectivePositions(CatchHitObject original, CatchHitObject mirrored) + => $"original X: {original.EffectiveX}, mirrored X is: {mirrored.EffectiveX}, mirrored X should be: {CatchPlayfield.WIDTH - original.EffectiveX}"; + + private static bool effectivePositionMirrored(CatchHitObject original, CatchHitObject mirrored) + => Precision.AlmostEquals(original.EffectiveX, CatchPlayfield.WIDTH - mirrored.EffectiveX); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs new file mode 100644 index 0000000000..8ae2bcca0e --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs @@ -0,0 +1,109 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.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 + { + private Catcher catcher; + + private readonly Container container; + + public TestSceneCatchSkinConfiguration() + { + 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 DroppedObjectContainer()) + { + 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 8359657f84..f291bfed13 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -31,23 +31,10 @@ namespace osu.Game.Rulesets.Catch.Tests [Resolved] private OsuConfigManager config { get; set; } - [Cached] - private readonly DroppedObjectContainer droppedObjectContainer; - - private readonly Container trailContainer; + private DroppedObjectContainer droppedObjectContainer; private TestCatcher catcher; - public TestSceneCatcher() - { - Add(trailContainer = new Container - { - Anchor = Anchor.Centre, - Depth = -1 - }); - Add(droppedObjectContainer = new DroppedObjectContainer()); - } - [SetUp] public void SetUp() => Schedule(() => { @@ -56,13 +43,17 @@ namespace osu.Game.Rulesets.Catch.Tests CircleSize = 0, }; - if (catcher != null) - Remove(catcher); + droppedObjectContainer = new DroppedObjectContainer(); - Add(catcher = new TestCatcher(trailContainer, difficulty) + Child = new Container { - Anchor = Anchor.Centre - }); + Anchor = Anchor.Centre, + Children = new Drawable[] + { + droppedObjectContainer, + catcher = new TestCatcher(droppedObjectContainer, difficulty), + } + }; }); [Test] @@ -299,15 +290,15 @@ namespace osu.Game.Rulesets.Catch.Tests { public IEnumerable CaughtObjects => this.ChildrenOfType(); - public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty) - : base(trailsTarget, difficulty) + public TestCatcher(DroppedObjectContainer droppedObjectTarget, IBeatmapDifficultyInfo difficulty) + : base(droppedObjectTarget, difficulty) { } } public class TestKiaiFruit : Fruit { - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 877e115e2f..7cae9b18b9 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -34,12 +34,13 @@ namespace osu.Game.Rulesets.Catch.Tests private ScheduledDelegate addManyFruit; - private BeatmapDifficulty beatmapDifficulty; + private IBeatmapDifficultyInfo beatmapDifficulty; public TestSceneCatcherArea() { AddSliderStep("circle size", 0, 8, 5, createCatcher); AddToggleStep("hyper dash", t => this.ChildrenOfType().ForEach(area => area.ToggleHyperDash(t))); + AddToggleStep("toggle hit lighting", lighting => config.SetValue(OsuSetting.HitLighting, lighting)); AddStep("catch centered fruit", () => attemptCatch(new Fruit())); AddStep("catch many random fruit", () => @@ -77,7 +78,7 @@ namespace osu.Game.Rulesets.Catch.Tests { area.OnNewResult(drawable, new CatchJudgementResult(fruit, new CatchJudgement()) { - Type = area.MovableCatcher.CanCatch(fruit) ? HitResult.Great : HitResult.Miss + Type = area.Catcher.CanCatch(fruit) ? HitResult.Great : HitResult.Miss }); drawable.Expire(); @@ -119,16 +120,18 @@ namespace osu.Game.Rulesets.Catch.Tests private class TestCatcherArea : CatcherArea { - [Cached] - private readonly DroppedObjectContainer droppedObjectContainer; - - public TestCatcherArea(BeatmapDifficulty beatmapDifficulty) - : base(beatmapDifficulty) + public TestCatcherArea(IBeatmapDifficultyInfo beatmapDifficulty) { - AddInternal(droppedObjectContainer = new DroppedObjectContainer()); + var droppedObjectContainer = new DroppedObjectContainer(); + Add(droppedObjectContainer); + + Catcher = new Catcher(droppedObjectContainer, beatmapDifficulty) + { + X = CatchPlayfield.CENTER_X + }; } - public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); + public void ToggleHyperDash(bool status) => Catcher.SetHyperDashState(status ? 2 : 1); } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index fd6a9c7b7b..e7c7dc3c98 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs @@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Catch.Tests private bool playfieldIsEmpty => !((CatchPlayfield)drawableRuleset.Playfield).AllHitObjects.Any(h => h.IsAlive); - private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).CatcherArea.MovableCatcher.CurrentState; + private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).Catcher.CurrentState; private void spawnFruits(bool hit = false) { @@ -162,7 +162,7 @@ namespace osu.Game.Rulesets.Catch.Tests float xCoords = CatchPlayfield.CENTER_X; if (drawableRuleset.Playfield is CatchPlayfield catchPlayfield) - catchPlayfield.CatcherArea.MovableCatcher.X = xCoords - x_offset; + catchPlayfield.Catcher.X = xCoords - x_offset; if (hit) xCoords -= x_offset; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs index db09b2bc6b..163fee49fb 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.Tests // this needs to be done within the frame stable context due to how quickly hyperdash state changes occur. Player.DrawableRuleset.FrameStableComponents.OnUpdate += d => { - var catcher = Player.ChildrenOfType().FirstOrDefault()?.MovableCatcher; + var catcher = Player.ChildrenOfType().FirstOrDefault(); if (catcher == null) return; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 7fa981d492..70b2c8c82a 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Catch.Tests } [Test] - public void TestCustomEndGlowColour() + public void TestCustomAfterImageColour() { var skin = new TestSkin { @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Tests } [Test] - public void TestCustomEndGlowColourPriority() + public void TestCustomAfterImageColourPriority() { var skin = new TestSkin { @@ -111,38 +111,45 @@ namespace osu.Game.Rulesets.Catch.Tests checkHyperDashFruitColour(skin, skin.HyperDashColour); } - private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedEndGlowColour = null) + private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedAfterImageColour = null) { - CatcherArea catcherArea = null; CatcherTrailDisplay trails = null; + Catcher catcher = null; AddStep("create hyper-dashing catcher", () => { - Child = setupSkinHierarchy(catcherArea = new TestCatcherArea + CatcherArea catcherArea; + Child = setupSkinHierarchy(new Container { Anchor = Anchor.Centre, - Origin = Anchor.Centre + Child = catcherArea = new CatcherArea + { + Catcher = catcher = new Catcher(new DroppedObjectContainer()) + { + Scale = new Vector2(4) + } + } }, skin); + trails = catcherArea.ChildrenOfType().Single(); }); - AddStep("get trails container", () => + AddStep("start hyper-dash", () => { - trails = catcherArea.OfType().Single(); - catcherArea.MovableCatcher.SetHyperDashState(2); + catcher.SetHyperDashState(2); }); - AddUntilStep("catcher colour is correct", () => catcherArea.MovableCatcher.Colour == expectedCatcherColour); + AddUntilStep("catcher colour is correct", () => catcher.Colour == expectedCatcherColour); AddAssert("catcher trails colours are correct", () => trails.HyperDashTrailsColour == expectedCatcherColour); - AddAssert("catcher end-glow colours are correct", () => trails.EndGlowSpritesColour == (expectedEndGlowColour ?? expectedCatcherColour)); + AddAssert("catcher after-image colours are correct", () => trails.HyperDashAfterImageColour == (expectedAfterImageColour ?? expectedCatcherColour)); AddStep("finish hyper-dashing", () => { - catcherArea.MovableCatcher.SetHyperDashState(1); - catcherArea.MovableCatcher.FinishTransforms(); + catcher.SetHyperDashState(); + catcher.FinishTransforms(); }); - AddAssert("catcher colour returned to white", () => catcherArea.MovableCatcher.Colour == Color4.White); + AddAssert("catcher colour returned to white", () => catcher.Colour == Color4.White); } private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour) @@ -205,18 +212,5 @@ 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 484da8e22e..6457ec92da 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -2,7 +2,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 3a5322ce82..87cc2c45e8 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs @@ -75,7 +75,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps case JuiceStream juiceStream: // Todo: BUG!! Stable used the last control point as the final position of the path, but it should use the computed path instead. - lastPosition = juiceStream.OriginalX + juiceStream.Path.ControlPoints[^1].Position.Value.X; + lastPosition = juiceStream.OriginalX + juiceStream.Path.ControlPoints[^1].Position.X; // Todo: BUG!! Stable attempted to use the end time of the stream, but referenced it too early in execution and used the start time instead. lastStartTime = juiceStream.StartTime; @@ -211,7 +211,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps palpableObjects.Sort((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); - double halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) / 2; + double halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) / 2; // Todo: This is wrong. osu!stable calculated hyperdashes using the full catcher size, excluding the margins. // This should theoretically cause impossible scenarios, but practically, likely due to the size of the playfield, it doesn't seem possible. diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 76863acc78..9fee6b2bc1 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -117,6 +117,7 @@ namespace osu.Game.Rulesets.Catch { new CatchModDifficultyAdjust(), new CatchModClassic(), + new CatchModMirror(), }; case ModType.Automation: @@ -130,7 +131,8 @@ namespace osu.Game.Rulesets.Catch return new Mod[] { new MultiMod(new ModWindUp(), new ModWindDown()), - new CatchModFloatingFruits() + new CatchModFloatingFruits(), + new CatchModMuted(), }; default: diff --git a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs index e736d68740..371e901c69 100644 --- a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs +++ b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs @@ -9,6 +9,7 @@ namespace osu.Game.Rulesets.Catch Banana, Droplet, Catcher, - CatchComboCounter + CatchComboCounter, + HitExplosion } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 9feaa55051..03a76f10ef 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty return new CatchDifficultyAttributes { Mods = mods, Skills = skills }; // this is the same as osu!, so there's potential to share the implementation... maybe - double preempt = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / clockRate; + double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; return new CatchDifficultyAttributes { @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. foreach (var hitObject in beatmap.HitObjects - .SelectMany(obj => obj is JuiceStream stream ? stream.NestedHitObjects : new[] { obj }) + .SelectMany(obj => obj is JuiceStream stream ? stream.NestedHitObjects.AsEnumerable() : new[] { obj }) .Cast() .OrderBy(x => x.StartTime)) { @@ -69,10 +69,10 @@ namespace osu.Game.Rulesets.Catch.Difficulty protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) * 0.5f; + halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. - halfCatcherWidth *= 1 - (Math.Max(0, beatmap.BeatmapInfo.BaseDifficulty.CircleSize - 5.5f) * 0.0625f); + halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); return new Skill[] { diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index fdd6ac0857..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,11 +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); + 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/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 4372ed938c..cfb3fe40be 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -9,7 +9,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Difficulty.Skills { - public class Movement : StrainSkill + public class Movement : StrainDecaySkill { private const float absolute_player_positioning_error = 16f; private const float normalized_hitobject_radius = 41.0f; diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs index 69054e2c81..5a32d241ad 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs @@ -6,6 +6,7 @@ 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 { @@ -23,5 +24,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints : 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 index 298f9474b0..7e566c810c 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs @@ -13,13 +13,14 @@ 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 { - float x = HitObject.OriginalX; - float y = HitObjectContainer.PositionAtTime(HitObject.StartTime); - return HitObjectContainer.ToScreenSpace(new Vector2(x, y + HitObjectContainer.DrawHeight)); + Vector2 position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + return HitObjectContainer.ToScreenSpace(position + new Vector2(0, HitObjectContainer.DrawHeight)); } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs new file mode 100644 index 0000000000..1a43a10c81 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs @@ -0,0 +1,190 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public abstract class EditablePath : CompositeDrawable + { + public int PathId => path.InvalidationID; + + public IReadOnlyList Vertices => path.Vertices; + + public int VertexCount => path.Vertices.Count; + + protected readonly Func PositionToDistance; + + protected IReadOnlyList VertexStates => vertexStates; + + private readonly JuiceStreamPath path = new JuiceStreamPath(); + + // Invariant: `path.Vertices.Count == vertexStates.Count` + private readonly List vertexStates = new List + { + new VertexState { IsFixed = true } + }; + + private readonly List previousVertexStates = new List(); + + [Resolved(CanBeNull = true)] + [CanBeNull] + private IBeatSnapProvider beatSnapProvider { get; set; } + + protected EditablePath(Func positionToDistance) + { + PositionToDistance = positionToDistance; + + Anchor = Anchor.BottomLeft; + } + + public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject) + { + while (path.Vertices.Count < InternalChildren.Count) + RemoveInternal(InternalChildren[^1]); + + while (InternalChildren.Count < path.Vertices.Count) + AddInternal(new VertexPiece()); + + double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity); + + for (int i = 0; i < VertexCount; i++) + { + var piece = (VertexPiece)InternalChildren[i]; + var vertex = path.Vertices[i]; + piece.Position = new Vector2(vertex.X, (float)(vertex.Distance * distanceToYFactor)); + piece.UpdateFrom(vertexStates[i]); + } + } + + public void InitializeFromHitObject(JuiceStream hitObject) + { + var sliderPath = hitObject.Path; + path.ConvertFromSliderPath(sliderPath); + + // If the original slider path has non-linear type segments, resample the vertices at nested hit object times to reduce the number of vertices. + if (sliderPath.ControlPoints.Any(p => p.Type != null && p.Type != PathType.Linear)) + { + path.ResampleVertices(hitObject.NestedHitObjects + .Skip(1).TakeWhile(h => !(h is Fruit)) // Only droplets in the first span are used. + .Select(h => (h.StartTime - hitObject.StartTime) * hitObject.Velocity)); + } + + vertexStates.Clear(); + vertexStates.AddRange(path.Vertices.Select((_, i) => new VertexState + { + IsFixed = i == 0 + })); + } + + public void UpdateHitObjectFromPath(JuiceStream hitObject) + { + path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY); + + if (beatSnapProvider == null) return; + + double endTime = hitObject.StartTime + path.Distance / hitObject.Velocity; + double snappedEndTime = beatSnapProvider.SnapTime(endTime, hitObject.StartTime); + hitObject.Path.ExpectedDistance.Value = (snappedEndTime - hitObject.StartTime) * hitObject.Velocity; + } + + public Vector2 ToRelativePosition(Vector2 screenSpacePosition) + { + return ToLocalSpace(screenSpacePosition) - new Vector2(0, DrawHeight); + } + + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + + protected int AddVertex(double distance, float x) + { + int index = path.InsertVertex(distance); + path.SetVertexPosition(index, x); + vertexStates.Insert(index, new VertexState()); + + correctFixedVertexPositions(); + + Debug.Assert(vertexStates.Count == VertexCount); + return index; + } + + protected bool RemoveVertex(int index) + { + if (index < 0 || index >= path.Vertices.Count) + return false; + + if (vertexStates[index].IsFixed) + return false; + + path.RemoveVertices((_, i) => i == index); + + vertexStates.RemoveAt(index); + if (vertexStates.Count == 0) + vertexStates.Add(new VertexState()); + + Debug.Assert(vertexStates.Count == VertexCount); + return true; + } + + protected void MoveSelectedVertices(double distanceDelta, float xDelta) + { + // Because the vertex list may be reordered due to distance change, the state list must be reordered as well. + previousVertexStates.Clear(); + previousVertexStates.AddRange(vertexStates); + + // We will recreate the path from scratch. Note that `Clear` leaves the first vertex. + int vertexCount = VertexCount; + path.Clear(); + vertexStates.RemoveRange(1, vertexCount - 1); + + for (int i = 1; i < vertexCount; i++) + { + var state = previousVertexStates[i]; + double distance = state.VertexBeforeChange.Distance; + if (state.IsSelected) + distance += distanceDelta; + + int newIndex = path.InsertVertex(Math.Max(0, distance)); + vertexStates.Insert(newIndex, state); + } + + // First, restore positions of the non-selected vertices. + for (int i = 0; i < vertexCount; i++) + { + if (!vertexStates[i].IsSelected && !vertexStates[i].IsFixed) + path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X); + } + + // Then, move the selected vertices. + for (int i = 0; i < vertexCount; i++) + { + if (vertexStates[i].IsSelected && !vertexStates[i].IsFixed) + path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X + xDelta); + } + + // Finally, correct the position of fixed vertices. + correctFixedVertexPositions(); + } + + private void correctFixedVertexPositions() + { + for (int i = 0; i < VertexCount; i++) + { + if (vertexStates[i].IsFixed) + path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs index 8769acc382..0c03068e26 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs @@ -7,7 +7,6 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Skinning.Default; -using osu.Game.Rulesets.UI.Scrolling; using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components @@ -18,7 +17,6 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { Anchor = Anchor.BottomLeft; Origin = Anchor.Centre; - Size = new Vector2(2 * CatchHitObject.OBJECT_RADIUS); InternalChild = new BorderPiece(); } @@ -28,10 +26,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components Colour = osuColour.Yellow; } - public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject hitObject) + public void UpdateFrom(CatchHitObject hitObject) { - X = hitObject.EffectiveX; - Y = hitObjectContainer.PositionAtTime(hitObject.StartTime); 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/PlacementEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs new file mode 100644 index 0000000000..158872fbab --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.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; +using osu.Game.Rulesets.Catch.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class PlacementEditablePath : EditablePath + { + /// + /// The original position of the last added vertex. + /// This is not same as the last vertex of the current path because the vertex ordering can change. + /// + private JuiceStreamPathVertex lastVertex; + + public PlacementEditablePath(Func positionToDistance) + : base(positionToDistance) + { + } + + public void AddNewVertex() + { + var endVertex = Vertices[^1]; + int index = AddVertex(endVertex.Distance, endVertex.X); + + for (int i = 0; i < VertexCount; i++) + { + VertexStates[i].IsSelected = i == index; + VertexStates[i].IsFixed = i != index; + VertexStates[i].VertexBeforeChange = Vertices[i]; + } + + lastVertex = Vertices[index]; + } + + /// + /// Move the vertex added by in the last time. + /// + public void MoveLastVertex(Vector2 screenSpacePosition) + { + Vector2 position = ToRelativePosition(screenSpacePosition); + double distanceDelta = PositionToDistance(position.Y) - lastVertex.Distance; + float xDelta = position.X - lastVertex.X; + MoveSelectedVertices(distanceDelta, xDelta); + } + } +} 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/SelectionEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs new file mode 100644 index 0000000000..8c7314d0b6 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Edit; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class SelectionEditablePath : EditablePath, IHasContextMenu + { + public MenuItem[] ContextMenuItems => getContextMenuItems().ToArray(); + + // To handle when the editor is scrolled while dragging. + private Vector2 dragStartPosition; + + [Resolved(CanBeNull = true)] + [CanBeNull] + private IEditorChangeHandler changeHandler { get; set; } + + public SelectionEditablePath(Func positionToDistance) + : base(positionToDistance) + { + } + + public void AddVertex(Vector2 relativePosition) + { + double distance = Math.Max(0, PositionToDistance(relativePosition.Y)); + int index = AddVertex(distance, relativePosition.X); + selectOnly(index); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => InternalChildren.Any(d => d.ReceivePositionalInputAt(screenSpacePos)); + + protected override bool OnMouseDown(MouseDownEvent e) + { + int index = getMouseTargetVertex(e.ScreenSpaceMouseDownPosition); + if (index == -1 || VertexStates[index].IsFixed) + return false; + + if (e.Button == MouseButton.Left && e.ShiftPressed) + { + RemoveVertex(index); + return true; + } + + if (e.ControlPressed) + VertexStates[index].IsSelected = !VertexStates[index].IsSelected; + else if (!VertexStates[index].IsSelected) + selectOnly(index); + + // Don't inhibit right click, to show the context menu + return e.Button != MouseButton.Right; + } + + protected override bool OnDragStart(DragStartEvent e) + { + int index = getMouseTargetVertex(e.ScreenSpaceMouseDownPosition); + if (index == -1 || VertexStates[index].IsFixed) + return false; + + if (e.Button != MouseButton.Left) + return false; + + dragStartPosition = ToRelativePosition(e.ScreenSpaceMouseDownPosition); + + for (int i = 0; i < VertexCount; i++) + VertexStates[i].VertexBeforeChange = Vertices[i]; + + changeHandler?.BeginChange(); + return true; + } + + protected override void OnDrag(DragEvent e) + { + Vector2 mousePosition = ToRelativePosition(e.ScreenSpaceMousePosition); + double distanceDelta = PositionToDistance(mousePosition.Y) - PositionToDistance(dragStartPosition.Y); + float xDelta = mousePosition.X - dragStartPosition.X; + MoveSelectedVertices(distanceDelta, xDelta); + } + + protected override void OnDragEnd(DragEndEvent e) + { + changeHandler?.EndChange(); + } + + private int getMouseTargetVertex(Vector2 screenSpacePosition) + { + for (int i = InternalChildren.Count - 1; i >= 0; i--) + { + if (i < VertexCount && InternalChildren[i].ReceivePositionalInputAt(screenSpacePosition)) + return i; + } + + return -1; + } + + private IEnumerable getContextMenuItems() + { + int selectedCount = VertexStates.Count(state => state.IsSelected); + + if (selectedCount != 0) + yield return new OsuMenuItem($"Delete selected {(selectedCount == 1 ? "vertex" : $"{selectedCount} vertices")}", MenuItemType.Destructive, deleteSelectedVertices); + } + + private void selectOnly(int index) + { + for (int i = 0; i < VertexCount; i++) + VertexStates[i].IsSelected = i == index; + } + + private void deleteSelectedVertices() + { + for (int i = VertexCount - 1; i >= 0; i--) + { + if (VertexStates[i].IsSelected) + RemoveVertex(i); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs new file mode 100644 index 0000000000..5ef86b6074 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class VertexPiece : Circle + { + [Resolved] + private OsuColour osuColour { get; set; } + + public VertexPiece() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.Centre; + Size = new Vector2(15); + } + + public void UpdateFrom(VertexState state) + { + Colour = state.IsSelected ? osuColour.Yellow.Lighten(1) : osuColour.Yellow; + Alpha = state.IsFixed ? 0.5f : 1; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs new file mode 100644 index 0000000000..3f240c7944 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Catch.Objects; + +#nullable enable + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + /// + /// Holds the state of a vertex in the path of a . + /// + public class VertexState + { + /// + /// Whether the vertex is selected. + /// + public bool IsSelected { get; set; } + + /// + /// Whether the vertex can be moved or deleted. + /// + public bool IsFixed { get; set; } + + /// + /// The position of the vertex before a vertex moving operation starts. + /// This is used to implement "memory-less" moving operations (only the final position matters) to improve UX. + /// + public JuiceStreamPathVertex VertexBeforeChange { get; set; } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs index 0f28cf6786..e169e3b75c 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs @@ -29,7 +29,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { base.Update(); - outline.UpdateFrom(HitObjectContainer, HitObject); + outline.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + outline.UpdateFrom(HitObject); } protected override bool OnMouseDown(MouseDownEvent e) diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs index 9665aac2fb..150297badb 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs @@ -20,8 +20,10 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { base.Update(); - if (IsSelected) - outline.UpdateFrom(HitObjectContainer, HitObject); + if (!IsSelected) return; + + outline.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + outline.UpdateFrom(HitObject); } } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs new file mode 100644 index 0000000000..cff5bc2417 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Input; +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; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class JuiceStreamPlacementBlueprint : CatchPlacementBlueprint + { + private readonly ScrollingPath scrollingPath; + + private readonly NestedOutlineContainer nestedOutlineContainer; + + private readonly PlacementEditablePath editablePath; + + private int lastEditablePathId = -1; + + private InputManager inputManager; + + public JuiceStreamPlacementBlueprint() + { + InternalChildren = new Drawable[] + { + scrollingPath = new ScrollingPath(), + nestedOutlineContainer = new NestedOutlineContainer(), + editablePath = new PlacementEditablePath(positionToDistance) + }; + } + + protected override void Update() + { + base.Update(); + + if (PlacementActive == PlacementState.Active) + editablePath.UpdateFrom(HitObjectContainer, HitObject); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + if (e.Button != MouseButton.Left) break; + + editablePath.AddNewVertex(); + BeginPlacement(true); + return true; + + case PlacementState.Active: + switch (e.Button) + { + case MouseButton.Left: + editablePath.AddNewVertex(); + return true; + + case MouseButton.Right: + EndPlacement(HitObject.Duration > 0); + return true; + } + + break; + } + + return base.OnMouseDown(e); + } + + public override void UpdateTimeAndPosition(SnapResult result) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + if (!(result.Time is double snappedTime)) return; + + HitObject.OriginalX = ToLocalSpace(result.ScreenSpacePosition).X; + HitObject.StartTime = snappedTime; + break; + + case PlacementState.Active: + Vector2 unsnappedPosition = inputManager.CurrentState.Mouse.Position; + editablePath.MoveLastVertex(unsnappedPosition); + break; + + default: + return; + } + + // Make sure the up-to-date position is used for outlines. + Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition; + + updateHitObjectFromPath(); + } + + private void updateHitObjectFromPath() + { + if (lastEditablePathId == editablePath.PathId) + return; + + editablePath.UpdateHitObjectFromPath(HitObject); + ApplyDefaultsToHitObject(); + + scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject); + nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject); + + lastEditablePathId = editablePath.PathId; + } + + private double positionToDistance(float relativeYPosition) + { + double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime); + return (time - HitObject.StartTime) * HitObject.Velocity; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs index d6b8c35a09..890d059d19 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs @@ -1,12 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { @@ -14,12 +24,46 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { public override Quad SelectionQuad => HitObjectContainer.ToScreenSpace(getBoundingBox().Offset(new Vector2(0, HitObjectContainer.DrawHeight))); + public override MenuItem[] ContextMenuItems => getContextMenuItems().ToArray(); + private float minNestedX; private float maxNestedX; + private readonly ScrollingPath scrollingPath; + + private readonly NestedOutlineContainer nestedOutlineContainer; + + private readonly Cached pathCache = new Cached(); + + private readonly SelectionEditablePath editablePath; + + /// + /// The of the corresponding the current of the hit object. + /// When the path is edited, the change is detected and the of the hit object is updated. + /// + private int lastEditablePathId = -1; + + /// + /// The of the current of the hit object. + /// When the of the hit object is changed by external means, the change is detected and the is re-initialized. + /// + private int lastSliderPathVersion = -1; + + private Vector2 rightMouseDownPosition; + + [Resolved(CanBeNull = true)] + [CanBeNull] + private EditorBeatmap editorBeatmap { get; set; } + public JuiceStreamSelectionBlueprint(JuiceStream hitObject) : base(hitObject) { + InternalChildren = new Drawable[] + { + scrollingPath = new ScrollingPath(), + nestedOutlineContainer = new NestedOutlineContainer(), + editablePath = new SelectionEditablePath(positionToDistance) + }; } [BackgroundDependencyLoader] @@ -29,7 +73,61 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints computeObjectBounds(); } - private void onDefaultsApplied(HitObject _) => computeObjectBounds(); + protected override void Update() + { + base.Update(); + + if (!IsSelected) return; + + if (editablePath.PathId != lastEditablePathId) + updateHitObjectFromPath(); + + Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition; + + editablePath.UpdateFrom(HitObjectContainer, HitObject); + + if (pathCache.IsValid) return; + + scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject); + nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject); + + pathCache.Validate(); + } + + protected override void OnSelected() + { + initializeJuiceStreamPath(); + base.OnSelected(); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (!IsSelected) return base.OnMouseDown(e); + + switch (e.Button) + { + case MouseButton.Left when e.ControlPressed: + editablePath.AddVertex(editablePath.ToRelativePosition(e.ScreenSpaceMouseDownPosition)); + return true; + + case MouseButton.Right: + // Record the mouse position to be used in the "add vertex" action. + rightMouseDownPosition = editablePath.ToRelativePosition(e.ScreenSpaceMouseDownPosition); + break; + } + + return base.OnMouseDown(e); + } + + private void onDefaultsApplied(HitObject _) + { + computeObjectBounds(); + pathCache.Invalidate(); + + if (lastSliderPathVersion != HitObject.Path.Version.Value) + initializeJuiceStreamPath(); + } private void computeObjectBounds() { @@ -47,6 +145,38 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints return new RectangleF(left, top, right - left, bottom - top).Inflate(objectRadius); } + private double positionToDistance(float relativeYPosition) + { + double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime); + return (time - HitObject.StartTime) * HitObject.Velocity; + } + + private void initializeJuiceStreamPath() + { + editablePath.InitializeFromHitObject(HitObject); + + // Record the current ID to update the hit object only when a change is made to the path. + lastEditablePathId = editablePath.PathId; + lastSliderPathVersion = HitObject.Path.Version.Value; + } + + private void updateHitObjectFromPath() + { + editablePath.UpdateHitObjectFromPath(HitObject); + editorBeatmap?.Update(HitObject); + + lastEditablePathId = editablePath.PathId; + lastSliderPathVersion = HitObject.Path.Version.Value; + } + + private IEnumerable getContextMenuItems() + { + yield return new OsuMenuItem("Add vertex", MenuItemType.Standard, () => + { + editablePath.AddVertex(rightMouseDownPosition); + }); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs index d383eb9ba6..046ba0ebce 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs @@ -9,7 +9,7 @@ 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) + public CatchEditorPlayfield(IBeatmapDifficultyInfo difficulty) : base(difficulty) { } @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Edit base.LoadComplete(); // TODO: honor "hit animation" setting? - CatcherArea.MovableCatcher.CatchFruitOnPlate = false; + Catcher.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 index d9712bc8e9..050c2f625d 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -2,6 +2,8 @@ // 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; @@ -20,12 +22,23 @@ namespace osu.Game.Rulesets.Catch.Edit { } + [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 JuiceStreamCompositionTool(), new BananaShowerCompositionTool() }; diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs new file mode 100644 index 0000000000..b059926668 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +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)); + } + + /// + /// Get the range of horizontal position occupied by the hit object. + /// + /// + /// s are excluded and returns . + /// + public static PositionRange GetPositionRange(HitObject hitObject) + { + switch (hitObject) + { + case Fruit fruit: + return new PositionRange(fruit.OriginalX); + + case Droplet droplet: + return droplet is TinyDroplet ? PositionRange.EMPTY : new PositionRange(droplet.OriginalX); + + case JuiceStream _: + return GetPositionRange(hitObject.NestedHitObjects); + + case BananaShower _: + // A banana shower occupies the whole screen width. + return new PositionRange(0, CatchPlayfield.WIDTH); + + default: + return PositionRange.EMPTY; + } + } + + /// + /// Get the range of horizontal position occupied by the hit objects. + /// + /// + /// s are excluded. + /// + public static PositionRange GetPositionRange(IEnumerable hitObjects) => hitObjects.Select(GetPositionRange).Aggregate(PositionRange.EMPTY, PositionRange.Union); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs index c1a491d1ce..8cb0804ab7 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs @@ -1,14 +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.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; +using Direction = osu.Framework.Graphics.Direction; namespace osu.Game.Rulesets.Catch.Edit { @@ -24,23 +28,114 @@ namespace osu.Game.Rulesets.Catch.Edit 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, SelectedItems); + + 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; + if (!(h is CatchHitObject catchObject)) return; - if (hitObject is BananaShower) return; - - // TODO: confine in bounds - hitObject.OriginalXBindable.Value += deltaX; + catchObject.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.OriginalXBindable.Value += deltaX; + foreach (var nested in catchObject.NestedHitObjects.OfType()) + nested.OriginalX += deltaX; }); return true; } + + public override bool HandleFlip(Direction direction) + { + var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems); + + bool changed = false; + EditorBeatmap.PerformOnSelection(h => + { + if (h is CatchHitObject catchObject) + changed |= handleFlip(selectionRange, catchObject); + }); + return changed; + } + + public override bool HandleReverse() + { + double selectionStartTime = SelectedItems.Min(h => h.StartTime); + double selectionEndTime = SelectedItems.Max(h => h.GetEndTime()); + + EditorBeatmap.PerformOnSelection(hitObject => + { + hitObject.StartTime = selectionEndTime - (hitObject.GetEndTime() - selectionStartTime); + + if (hitObject is JuiceStream juiceStream) + { + juiceStream.Path.Reverse(out Vector2 positionalOffset); + juiceStream.OriginalX += positionalOffset.X; + juiceStream.LegacyConvertedY += positionalOffset.Y; + EditorBeatmap.Update(juiceStream); + } + }); + return true; + } + + protected override void OnSelectionChanged() + { + base.OnSelectionChanged(); + + var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems); + SelectionBox.CanFlipX = selectionRange.Length > 0 && SelectedItems.Any(h => h is CatchHitObject && !(h is BananaShower)); + SelectionBox.CanReverse = SelectedItems.Count > 1 || SelectedItems.Any(h => h is JuiceStream); + } + + /// + /// 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) + { + var range = CatchHitObjectUtils.GetPositionRange(movingObjects); + // 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 = -range.Min; + float upperBound = CatchPlayfield.WIDTH - range.Max; + // 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); + } + + private bool handleFlip(PositionRange selectionRange, CatchHitObject hitObject) + { + switch (hitObject) + { + case BananaShower _: + return false; + + case JuiceStream juiceStream: + juiceStream.OriginalX = selectionRange.GetFlippedPosition(juiceStream.OriginalX); + + foreach (var point in juiceStream.Path.ControlPoints) + point.Position *= new Vector2(-1, 1); + + EditorBeatmap.Update(juiceStream); + return true; + + default: + hitObject.OriginalX = selectionRange.GetFlippedPosition(hitObject.OriginalX); + return true; + } + } } } diff --git a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs index 0344709d45..9a7528d90c 100644 --- a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs +++ b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs @@ -16,6 +16,6 @@ namespace osu.Game.Rulesets.Catch.Edit { } - protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.BeatmapInfo.BaseDifficulty); + protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.Difficulty); } } diff --git a/osu.Game.Rulesets.Catch/Edit/JuiceStreamCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/JuiceStreamCompositionTool.cs new file mode 100644 index 0000000000..cb66e2952e --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/JuiceStreamCompositionTool.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 JuiceStreamCompositionTool : HitObjectCompositionTool + { + public JuiceStreamCompositionTool() + : base(nameof(JuiceStream)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + + public override PlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/PositionRange.cs b/osu.Game.Rulesets.Catch/Edit/PositionRange.cs new file mode 100644 index 0000000000..e61603e5e6 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/PositionRange.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +#nullable enable + +namespace osu.Game.Rulesets.Catch.Edit +{ + /// + /// Represents either the empty range or a closed interval of horizontal positions in the playfield. + /// A represents a closed interval if it is <= , and represents the empty range otherwise. + /// + public readonly struct PositionRange + { + public readonly float Min; + public readonly float Max; + + public float Length => Math.Max(0, Max - Min); + + public PositionRange(float value) + : this(value, value) + { + } + + public PositionRange(float min, float max) + { + Min = min; + Max = max; + } + + public static PositionRange Union(PositionRange a, PositionRange b) => new PositionRange(Math.Min(a.Min, b.Min), Math.Max(a.Max, b.Max)); + + /// + /// Get the given position flipped (mirrored) for the axis at the center of this range. + /// Returns the given position unchanged if the range was empty. + /// + public float GetFlippedPosition(float x) => Min <= Max ? Max - (x - Min) : x; + + public static readonly PositionRange EMPTY = new PositionRange(float.PositiveInfinity, float.NegativeInfinity); + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index bd7a1df2e4..e59a0a0431 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -12,37 +12,29 @@ namespace osu.Game.Rulesets.Catch.Mods { 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, }; [SettingSource("Spicy Patterns", "Adjust the patterns as if Hard Rock is enabled.")] public BindableBool HardRockOffsets { get; } = new BindableBool(); - protected override void ApplyLimits(bool extended) - { - base.ApplyLimits(extended); - - CircleSize.MaxValue = extended ? 11 : 10; - ApproachRate.MaxValue = extended ? 11 : 10; - } - public override string SettingDescription { get @@ -61,20 +53,12 @@ namespace osu.Game.Rulesets.Catch.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; } public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor) diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs index 71268d899d..f399f48ebd 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs @@ -41,9 +41,7 @@ namespace osu.Game.Rulesets.Catch.Mods { base.Update(); - var catcherArea = playfield.CatcherArea; - - FlashlightPosition = catcherArea.ToSpaceOfOtherDrawable(catcherArea.MovableCatcher.DrawPosition, this); + FlashlightPosition = playfield.CatcherArea.ToSpaceOfOtherDrawable(playfield.Catcher.DrawPosition, this); } private float getSizeFor(int combo) diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index f9e106f097..d48edbcd74 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Catch.Mods var drawableCatchRuleset = (DrawableCatchRuleset)drawableRuleset; var catchPlayfield = (CatchPlayfield)drawableCatchRuleset.Playfield; - catchPlayfield.CatcherArea.MovableCatcher.CatchFruitOnPlate = false; + catchPlayfield.Catcher.CatchFruitOnPlate = false; } protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs new file mode 100644 index 0000000000..a97e940a64 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModMirror : ModMirror, IApplicableToBeatmap + { + public override string Description => "Fruits are flipped horizontally."; + + /// + /// is used instead of , + /// as applies offsets in . + /// runs after post-processing, while runs before it. + /// + public void ApplyToBeatmap(IBeatmap beatmap) + { + foreach (var hitObject in beatmap.HitObjects) + applyToHitObject(hitObject); + } + + private void applyToHitObject(HitObject hitObject) + { + var catchObject = (CatchHitObject)hitObject; + + switch (catchObject) + { + case Fruit fruit: + mirrorEffectiveX(fruit); + break; + + case JuiceStream juiceStream: + mirrorEffectiveX(juiceStream); + mirrorJuiceStreamPath(juiceStream); + break; + + case BananaShower bananaShower: + mirrorBananaShower(bananaShower); + break; + } + } + + /// + /// Mirrors the effective X position of and its nested hit objects. + /// + private static void mirrorEffectiveX(CatchHitObject catchObject) + { + catchObject.OriginalX = CatchPlayfield.WIDTH - catchObject.OriginalX; + catchObject.XOffset = -catchObject.XOffset; + + foreach (var nested in catchObject.NestedHitObjects.Cast()) + { + nested.OriginalX = CatchPlayfield.WIDTH - nested.OriginalX; + nested.XOffset = -nested.XOffset; + } + } + + /// + /// Mirrors the path of the . + /// + private static void mirrorJuiceStreamPath(JuiceStream juiceStream) + { + var controlPoints = juiceStream.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); + foreach (var point in controlPoints) + point.Position = new Vector2(-point.Position.X, point.Position.Y); + + juiceStream.Path = new SliderPath(controlPoints, juiceStream.Path.ExpectedDistance.Value); + } + + /// + /// Mirrors X positions of all bananas in the . + /// + private static void mirrorBananaShower(BananaShower bananaShower) + { + foreach (var banana in bananaShower.NestedHitObjects.OfType()) + banana.XOffset = CatchPlayfield.WIDTH - banana.XOffset; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModMuted.cs b/osu.Game.Rulesets.Catch/Mods/CatchModMuted.cs new file mode 100644 index 0000000000..6d2565440a --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModMuted.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModMuted : ModMuted + { + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs index 73b60f51a4..d0a94767d1 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs @@ -44,9 +44,9 @@ namespace osu.Game.Rulesets.Catch.Mods } // disable keyboard controls - public bool OnPressed(CatchAction action) => true; + public bool OnPressed(KeyBindingPressEvent e) => true; - public void OnReleased(CatchAction action) + public void OnReleased(KeyBindingReleaseEvent 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..ee10cf9711 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(); @@ -85,6 +95,14 @@ namespace osu.Game.Rulesets.Catch.Objects set => ComboIndexBindable.Value = value; } + public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + + public int ComboIndexWithOffsets + { + get => ComboIndexWithOffsetsBindable.Value; + set => ComboIndexWithOffsetsBindable.Value = value; + } + public Bindable LastInComboBindable { get; } = new Bindable(); /// @@ -110,15 +128,34 @@ namespace osu.Game.Rulesets.Catch.Objects /// public int RandomSeed => (int)StartTime; - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); + TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; } 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/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 35fd58826e..0d6925a83d 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; } /// @@ -33,7 +37,7 @@ namespace osu.Game.Rulesets.Catch.Objects /// public double SpanDuration => Duration / this.SpanCount(); - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); @@ -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; @@ -133,7 +138,7 @@ namespace osu.Game.Rulesets.Catch.Objects if (value != null) { - path.ControlPoints.AddRange(value.ControlPoints.Select(c => new PathControlPoint(c.Position.Value, c.Type.Value))); + path.ControlPoints.AddRange(value.ControlPoints.Select(c => new PathControlPoint(c.Position, c.Type))); path.ExpectedDistance.Value = value.ExpectedDistance.Value; } } diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs new file mode 100644 index 0000000000..7207833fe6 --- /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 = 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/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs index a81703119a..2fc05701db 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs @@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Replays bool impossibleJump = speedRequired > movement_speed * 2; // todo: get correct catcher size, based on difficulty CS. - const float catcher_width_half = CatcherArea.CATCHER_SIZE * 0.3f * 0.5f; + const float catcher_width_half = Catcher.BASE_SIZE * 0.3f * 0.5f; if (lastPosition - catcher_width_half < h.EffectiveX && lastPosition + catcher_width_half > h.EffectiveX) { 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/DefaultHitExplosion.cs b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs new file mode 100644 index 0000000000..e1fad564a3 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs @@ -0,0 +1,129 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Skinning.Default +{ + public class DefaultHitExplosion : CompositeDrawable, IHitExplosion + { + private CircularContainer largeFaint; + private CircularContainer smallFaint; + private CircularContainer directionalGlow1; + private CircularContainer directionalGlow2; + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(20); + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + + // scale roughly in-line with visual appearance of notes + const float initial_height = 10; + + InternalChildren = new Drawable[] + { + largeFaint = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Blending = BlendingParameters.Additive, + }, + smallFaint = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Blending = BlendingParameters.Additive, + }, + directionalGlow1 = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Size = new Vector2(0.01f, initial_height), + Blending = BlendingParameters.Additive, + }, + directionalGlow2 = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Size = new Vector2(0.01f, initial_height), + Blending = BlendingParameters.Additive, + } + }; + } + + public void Animate(HitExplosionEntry entry) + { + X = entry.Position; + Scale = new Vector2(entry.HitObject.Scale); + setColour(entry.ObjectColour); + + using (BeginAbsoluteSequence(entry.LifetimeStart)) + applyTransforms(entry.HitObject.RandomSeed); + } + + private void applyTransforms(int randomSeed) + { + const double duration = 400; + + // we want our size to be very small so the glow dominates it. + largeFaint.Size = new Vector2(0.8f); + largeFaint + .ResizeTo(largeFaint.Size * new Vector2(5, 1), duration, Easing.OutQuint) + .FadeOut(duration * 2); + + const float angle_variangle = 15; // should be less than 45 + 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); + } + + private void setColour(Color4 objectColour) + { + const float roundness = 100; + + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.1f, objectColour, Color4.White, 0, 1).Opacity(0.3f), + Roundness = 160, + Radius = 200, + }; + + smallFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.6f, objectColour, Color4.White, 0, 1), + Roundness = 20, + Radius = 50, + }; + + directionalGlow1.EdgeEffect = directionalGlow2.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.4f, objectColour, Color4.White, 0, 1), + Roundness = roundness, + Radius = 40, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs index 287ed1b4c7..10fc4e78b2 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs @@ -70,13 +70,11 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy if (version < 2.3m) { - if (GetTexture(@"fruit-ryuuta") != null || - GetTexture(@"fruit-ryuuta-0") != null) + if (hasOldStyleCatcherSprite()) return new LegacyCatcherOld(); } - if (GetTexture(@"fruit-catcher-idle") != null || - GetTexture(@"fruit-catcher-idle-0") != null) + if (hasNewStyleCatcherSprite()) return new LegacyCatcherNew(); return null; @@ -86,12 +84,26 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy return new LegacyCatchComboCounter(Skin); return null; + + case CatchSkinComponents.HitExplosion: + if (hasOldStyleCatcherSprite() || hasNewStyleCatcherSprite()) + return new LegacyHitExplosion(); + + return null; } } return base.GetDrawableComponent(component); } + private bool hasOldStyleCatcherSprite() => + GetTexture(@"fruit-ryuuta") != null + || GetTexture(@"fruit-ryuuta-0") != null; + + private bool hasNewStyleCatcherSprite() => + GetTexture(@"fruit-catcher-idle") != null + || GetTexture(@"fruit-catcher-idle-0") != null; + public override IBindable GetConfig(TLookup lookup) { switch (lookup) @@ -103,6 +115,19 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy 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 base.GetConfig(lookup); diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs new file mode 100644 index 0000000000..c262b0a4ac --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Skinning.Legacy +{ + public class LegacyHitExplosion : CompositeDrawable, IHitExplosion + { + [Resolved] + private Catcher catcher { get; set; } + + private const float catch_margin = (1 - Catcher.ALLOWED_CATCH_RANGE) / 2; + + private readonly Sprite explosion1; + private readonly Sprite explosion2; + + public LegacyHitExplosion() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + RelativeSizeAxes = Axes.Both; + Scale = new Vector2(0.5f); + + InternalChildren = new[] + { + explosion1 = new Sprite + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.CentreLeft, + Alpha = 0, + Blending = BlendingParameters.Additive, + Rotation = -90 + }, + explosion2 = new Sprite + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.CentreLeft, + Alpha = 0, + Blending = BlendingParameters.Additive, + Rotation = -90 + } + }; + } + + [BackgroundDependencyLoader] + private void load(SkinManager skins) + { + var defaultLegacySkin = skins.DefaultLegacySkin; + + // sprite names intentionally swapped to match stable member naming / ease of cross-referencing + explosion1.Texture = defaultLegacySkin.GetTexture("scoreboard-explosion-2"); + explosion2.Texture = defaultLegacySkin.GetTexture("scoreboard-explosion-1"); + } + + public void Animate(HitExplosionEntry entry) + { + Colour = entry.ObjectColour; + + using (BeginAbsoluteSequence(entry.LifetimeStart)) + { + float halfCatchWidth = catcher.CatchWidth / 2; + float explosionOffset = Math.Clamp(entry.Position, -halfCatchWidth + catch_margin * 3, halfCatchWidth - catch_margin * 3); + + if (!(entry.HitObject is Droplet)) + { + float scale = Math.Clamp(entry.JudgementResult.ComboAtJudgement / 200f, 0.35f, 1.125f); + + explosion1.Scale = new Vector2(1, 0.9f); + explosion1.Position = new Vector2(explosionOffset, 0); + + explosion1.FadeOutFromOne(300); + explosion1.ScaleTo(new Vector2(16 * scale, 1.1f), 160, Easing.Out); + } + + explosion2.Scale = new Vector2(0.9f, 1); + explosion2.Position = new Vector2(explosionOffset, 0); + + explosion2.FadeOutFromOne(700); + explosion2.ScaleTo(new Vector2(0.9f, 1.3f), 500, Easing.Out); + + this.Delay(700).FadeOutFromOne(); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 05cd29dff5..df32d917ce 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -26,38 +26,47 @@ 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) - { - CatcherArea = new CatcherArea(difficulty) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopLeft, - }; + internal Catcher Catcher { get; private set; } - InternalChildren = new[] - { - droppedObjectContainer = new DroppedObjectContainer(), - CatcherArea.MovableCatcher.CreateProxiedContent(), - HitObjectContainer.CreateProxy(), - // This ordering (`CatcherArea` before `HitObjectContainer`) is important to - // make sure the up-to-date catcher position is used for the catcher catching logic of hit objects. - CatcherArea, - HitObjectContainer, - }; + internal CatcherArea CatcherArea { get; private set; } + + private readonly IBeatmapDifficultyInfo difficulty; + + public CatchPlayfield(IBeatmapDifficultyInfo difficulty) + { + this.difficulty = difficulty; } [BackgroundDependencyLoader] private void load() { + var droppedObjectContainer = new DroppedObjectContainer(); + + Catcher = new Catcher(droppedObjectContainer, difficulty) + { + X = CENTER_X + }; + + AddRangeInternal(new[] + { + droppedObjectContainer, + Catcher.CreateProxiedContent(), + HitObjectContainer.CreateProxy(), + // This ordering (`CatcherArea` before `HitObjectContainer`) is important to + // make sure the up-to-date catcher position is used for the catcher catching logic of hit objects. + CatcherArea = new CatcherArea + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + Catcher = Catcher, + }, + HitObjectContainer, + }); + RegisterPool(50); RegisterPool(50); RegisterPool(100); @@ -80,7 +89,7 @@ namespace osu.Game.Rulesets.Catch.UI ((DrawableCatchHitObject)d).CheckPosition = checkIfWeCanCatch; } - private bool checkIfWeCanCatch(CatchHitObject obj) => CatcherArea.MovableCatcher.CanCatch(obj); + private bool checkIfWeCanCatch(CatchHitObject obj) => Catcher.CanCatch(obj); private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) => CatcherArea.OnNewResult((DrawableCatchHitObject)judgedObject, result); diff --git a/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs index 1ddb5ac630..a7879846df 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs @@ -21,6 +21,6 @@ namespace osu.Game.Rulesets.Catch.UI } protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) - => new CatchReplayFrame(Time.Current, playfield.CatcherArea.MovableCatcher.X, actions.Contains(CatchAction.Dash), previousFrame as CatchReplayFrame); + => new CatchReplayFrame(Time.Current, playfield.Catcher.X, actions.Contains(CatchAction.Dash), previousFrame as CatchReplayFrame); } } diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index f2e29b2e10..3745099010 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -23,11 +23,21 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI { + [Cached] public class Catcher : SkinReloadableDrawable { /// - /// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail - /// and end glow/after-image during a hyper-dash. + /// The size of the catcher at 1x scale. + /// + public const float BASE_SIZE = 106.75f; + + /// + /// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable. + /// + public const float ALLOWED_CATCH_RANGE = 0.8f; + + /// + /// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail and after-image during a hyper-dash. /// public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red; @@ -61,11 +71,6 @@ namespace osu.Game.Rulesets.Catch.UI /// private const float caught_fruit_scale_adjust = 0.5f; - [NotNull] - private readonly Container trailsTarget; - - private CatcherTrailDisplay trails; - /// /// Contains caught objects on the plate. /// @@ -74,8 +79,7 @@ namespace osu.Game.Rulesets.Catch.UI /// /// Contains objects dropped from the plate. /// - [Resolved] - private DroppedObjectContainer droppedObjectTarget { get; set; } + private readonly DroppedObjectContainer droppedObjectTarget; public CatcherAnimationState CurrentState { @@ -84,40 +88,30 @@ namespace osu.Game.Rulesets.Catch.UI } /// - /// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable. + /// Whether the catcher is currently dashing. /// - public const float ALLOWED_CATCH_RANGE = 0.8f; + public bool Dashing { get; set; } - private bool dashing; + /// + /// The currently facing direction. + /// + public Direction VisualDirection { get; set; } = Direction.Right; - public bool Dashing - { - get => dashing; - set - { - if (value == dashing) return; + public Vector2 BodyScale => Scale * body.Scale; - dashing = value; - - updateTrailVisibility(); - } - } - - public Direction VisualDirection - { - get => Scale.X > 0 ? Direction.Right : Direction.Left; - set => Scale = new Vector2((value == Direction.Right ? 1 : -1) * Math.Abs(Scale.X), Scale.Y); - } + /// + /// 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; + public readonly float CatchWidth; private readonly SkinnableCatcher body; private Color4 hyperDashColour = DEFAULT_HYPER_DASH_COLOUR; - private Color4 hyperDashEndGlowColour = DEFAULT_HYPER_DASH_COLOUR; private double hyperDashModifier = 1; private int hyperDashDirection; @@ -130,17 +124,17 @@ namespace osu.Game.Rulesets.Catch.UI private readonly DrawablePool caughtBananaPool; private readonly DrawablePool caughtDropletPool; - public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) + public Catcher([NotNull] DroppedObjectContainer droppedObjectTarget, IBeatmapDifficultyInfo difficulty = null) { - this.trailsTarget = trailsTarget; + this.droppedObjectTarget = droppedObjectTarget; Origin = Anchor.TopCentre; - Size = new Vector2(CatcherArea.CATCHER_SIZE); + Size = new Vector2(BASE_SIZE); if (difficulty != null) Scale = calculateScale(difficulty); - catchWidth = CalculateCatchWidth(Scale); + CatchWidth = CalculateCatchWidth(Scale); InternalChildren = new Drawable[] { @@ -168,15 +162,6 @@ namespace osu.Game.Rulesets.Catch.UI private void load(OsuConfigManager config) { hitLighting = config.GetBindable(OsuSetting.HitLighting); - trails = new CatcherTrailDisplay(this); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - // don't add in above load as we may potentially modify a parent in an unsafe manner. - trailsTarget.Add(trails); } /// @@ -187,19 +172,19 @@ namespace osu.Game.Rulesets.Catch.UI /// /// Calculates the scale of the catcher based off the provided beatmap difficulty. /// - private static Vector2 calculateScale(BeatmapDifficulty difficulty) => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); + private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); /// /// Calculates the width of the area used for attempting catches in gameplay. /// /// The scale of the catcher. - public static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; + public static float CalculateCatchWidth(Vector2 scale) => BASE_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; /// /// Calculates the width of the area used for attempting catches in gameplay. /// /// The beatmap difficulty. - public static float CalculateCatchWidth(BeatmapDifficulty difficulty) => CalculateCatchWidth(calculateScale(difficulty)); + public static float CalculateCatchWidth(IBeatmapDifficultyInfo difficulty) => CalculateCatchWidth(calculateScale(difficulty)); /// /// Determine if this catcher can catch a in the current position. @@ -209,14 +194,9 @@ namespace osu.Game.Rulesets.Catch.UI if (!(hitObject is PalpableCatchHitObject fruit)) return false; - var halfCatchWidth = catchWidth * 0.5f; - - // this stuff wil disappear once we move fruit to non-relative coordinate space in the future. - var catchObjectPosition = fruit.EffectiveX; - var catcherPosition = Position.X; - - return catchObjectPosition >= catcherPosition - halfCatchWidth && - catchObjectPosition <= catcherPosition + halfCatchWidth; + float halfCatchWidth = CatchWidth * 0.5f; + return fruit.EffectiveX >= X - halfCatchWidth && + fruit.EffectiveX <= X + halfCatchWidth; } public void OnNewResult(DrawableCatchHitObject drawableObject, JudgementResult result) @@ -237,7 +217,7 @@ namespace osu.Game.Rulesets.Catch.UI placeCaughtObject(palpableObject, positionInStack); if (hitLighting.Value) - addLighting(hitObject, positionInStack.X, drawableObject.AccentColour.Value); + addLighting(result, drawableObject.AccentColour.Value, positionInStack.X); } // droplet doesn't affect the catcher state @@ -303,10 +283,7 @@ namespace osu.Game.Rulesets.Catch.UI hyperDashTargetPosition = targetPosition; if (!wasHyperDashing) - { - trails.DisplayEndGlow(); runHyperDashStateTransition(true); - } } } @@ -322,13 +299,9 @@ namespace osu.Game.Rulesets.Catch.UI private void runHyperDashStateTransition(bool hyperDashing) { - updateTrailVisibility(); - this.FadeColour(hyperDashing ? hyperDashColour : Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); } - private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; - protected override void SkinChanged(ISkinSource skin) { base.SkinChanged(skin); @@ -337,12 +310,7 @@ namespace osu.Game.Rulesets.Catch.UI skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? DEFAULT_HYPER_DASH_COLOUR; - hyperDashEndGlowColour = - skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value ?? - hyperDashColour; - - trails.HyperDashTrailsColour = hyperDashColour; - trails.EndGlowSpritesColour = hyperDashEndGlowColour; + flipCatcherPlate = skin.GetConfig(CatchSkinConfiguration.FlipCatcherPlate)?.Value ?? true; runHyperDashStateTransition(HyperDashing); } @@ -351,6 +319,10 @@ namespace osu.Game.Rulesets.Catch.UI { base.Update(); + 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) || (hyperDashDirection < 0 && hyperDashTargetPosition > X)) @@ -394,8 +366,8 @@ namespace osu.Game.Rulesets.Catch.UI return position; } - private void addLighting(CatchHitObject hitObject, float x, Color4 colour) => - hitExplosionContainer.Add(new HitExplosionEntry(Time.Current, x, hitObject.Scale, colour, hitObject.RandomSeed)); + private void addLighting(JudgementResult judgementResult, Color4 colour, float x) => + hitExplosionContainer.Add(new HitExplosionEntry(Time.Current, judgementResult, colour, x)); private CaughtObject getCaughtObject(PalpableCatchHitObject source) { @@ -459,7 +431,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 fea314df8d..604e878782 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -5,7 +5,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; -using osu.Game.Beatmaps; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Replays; @@ -16,13 +16,27 @@ using osuTK; namespace osu.Game.Rulesets.Catch.UI { + /// + /// The horizontal band at the bottom of the playfield the catcher is moving on. + /// It holds a as a child and translates input to the catcher movement. + /// It also holds a combo display that is above the catcher, and judgment results are translated to the catcher and the combo display. + /// public class CatcherArea : Container, IKeyBindingHandler { - public const float CATCHER_SIZE = 106.75f; + public Catcher Catcher + { + get => catcher; + set => catcherContainer.Child = catcher = value; + } + + private readonly Container catcherContainer; - public readonly Catcher MovableCatcher; private readonly CatchComboDisplay comboDisplay; + private readonly CatcherTrailDisplay catcherTrails; + + private Catcher catcher; + /// /// -1 when only left button is pressed. /// 1 when only right button is pressed. @@ -30,11 +44,19 @@ namespace osu.Game.Rulesets.Catch.UI /// private int currentDirection; - public CatcherArea(BeatmapDifficulty difficulty = null) + // TODO: support replay rewind + private bool lastHyperDashState; + + /// + /// must be set before loading. + /// + public CatcherArea() { - Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); + Size = new Vector2(CatchPlayfield.WIDTH, Catcher.BASE_SIZE); Children = new Drawable[] { + catcherContainer = new Container { RelativeSizeAxes = Axes.Both }, + catcherTrails = new CatcherTrailDisplay(), comboDisplay = new CatchComboDisplay { RelativeSizeAxes = Axes.None, @@ -43,14 +65,13 @@ namespace osu.Game.Rulesets.Catch.UI Origin = Anchor.Centre, Margin = new MarginPadding { Bottom = 350f }, X = CatchPlayfield.CENTER_X - }, - MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }, + } }; } public void OnNewResult(DrawableCatchHitObject hitObject, JudgementResult result) { - MovableCatcher.OnNewResult(hitObject, result); + Catcher.OnNewResult(hitObject, result); if (!result.Type.IsScorable()) return; @@ -58,9 +79,9 @@ namespace osu.Game.Rulesets.Catch.UI if (hitObject.HitObject.LastInCombo) { if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result)) - MovableCatcher.Explode(); + Catcher.Explode(); else - MovableCatcher.Drop(); + Catcher.Drop(); } comboDisplay.OnNewResult(hitObject, result); @@ -69,7 +90,7 @@ namespace osu.Game.Rulesets.Catch.UI public void OnRevertResult(DrawableCatchHitObject hitObject, JudgementResult result) { comboDisplay.OnRevertResult(hitObject, result); - MovableCatcher.OnRevertResult(hitObject, result); + Catcher.OnRevertResult(hitObject, result); } protected override void Update() @@ -80,32 +101,53 @@ namespace osu.Game.Rulesets.Catch.UI SetCatcherPosition( replayState?.CatcherX ?? - (float)(MovableCatcher.X + MovableCatcher.Speed * currentDirection * Clock.ElapsedFrameTime)); + (float)(Catcher.X + Catcher.Speed * currentDirection * Clock.ElapsedFrameTime)); } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - comboDisplay.X = MovableCatcher.X; + comboDisplay.X = Catcher.X; + + if (Time.Elapsed <= 0) + { + // This is probably a wrong value, but currently the true value is not recorded. + // Setting `true` will prevent generation of false-positive after-images (with more false-negatives). + lastHyperDashState = true; + return; + } + + if (!lastHyperDashState && Catcher.HyperDashing) + displayCatcherTrail(CatcherTrailAnimation.HyperDashAfterImage); + + if (Catcher.Dashing || Catcher.HyperDashing) + { + double generationInterval = Catcher.HyperDashing ? 25 : 50; + + if (Time.Current - catcherTrails.LastDashTrailTime >= generationInterval) + displayCatcherTrail(Catcher.HyperDashing ? CatcherTrailAnimation.HyperDashing : CatcherTrailAnimation.Dashing); + } + + lastHyperDashState = Catcher.HyperDashing; } public void SetCatcherPosition(float X) { - float lastPosition = MovableCatcher.X; + float lastPosition = Catcher.X; float newPosition = Math.Clamp(X, 0, CatchPlayfield.WIDTH); - MovableCatcher.X = newPosition; + Catcher.X = newPosition; if (lastPosition < newPosition) - MovableCatcher.VisualDirection = Direction.Right; + Catcher.VisualDirection = Direction.Right; else if (lastPosition > newPosition) - MovableCatcher.VisualDirection = Direction.Left; + Catcher.VisualDirection = Direction.Left; } - public bool OnPressed(CatchAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case CatchAction.MoveLeft: currentDirection--; @@ -116,16 +158,16 @@ namespace osu.Game.Rulesets.Catch.UI return true; case CatchAction.Dash: - MovableCatcher.Dashing = true; + Catcher.Dashing = true; return true; } return false; } - public void OnReleased(CatchAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - switch (action) + switch (e.Action) { case CatchAction.MoveLeft: currentDirection++; @@ -136,9 +178,11 @@ namespace osu.Game.Rulesets.Catch.UI break; case CatchAction.Dash: - MovableCatcher.Dashing = false; + Catcher.Dashing = false; break; } } + + private void displayCatcherTrail(CatcherTrailAnimation animation) => catcherTrails.Add(new CatcherTrailEntry(Time.Current, Catcher.CurrentState, Catcher.X, Catcher.BodyScale, animation)); } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs index c961d98dc5..6d2ac7e488 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs @@ -2,8 +2,8 @@ // 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 osu.Game.Rulesets.Objects.Pooling; using osuTK; namespace osu.Game.Rulesets.Catch.UI @@ -12,18 +12,13 @@ namespace osu.Game.Rulesets.Catch.UI /// A trail of the catcher. /// It also represents a hyper dash afterimage. /// - public class CatcherTrail : PoolableDrawable + public class CatcherTrail : PoolableDrawableWithLifetime { - public CatcherAnimationState AnimationState - { - set => body.AnimationState.Value = value; - } - private readonly SkinnableCatcher body; public CatcherTrail() { - Size = new Vector2(CatcherArea.CATCHER_SIZE); + Size = new Vector2(Catcher.BASE_SIZE); Origin = Anchor.TopCentre; Blending = BlendingParameters.Additive; InternalChild = body = new SkinnableCatcher @@ -34,11 +29,40 @@ namespace osu.Game.Rulesets.Catch.UI }; } - protected override void FreeAfterUse() + protected override void OnApply(CatcherTrailEntry entry) { + Position = new Vector2(entry.Position, 0); + Scale = entry.Scale; + + body.AnimationState.Value = entry.CatcherState; + + using (BeginAbsoluteSequence(entry.LifetimeStart, false)) + applyTransforms(entry.Animation); + } + + protected override void OnFree(CatcherTrailEntry entry) + { + ApplyTransformsAt(double.MinValue); ClearTransforms(); - Alpha = 1; - base.FreeAfterUse(); + } + + private void applyTransforms(CatcherTrailAnimation animation) + { + switch (animation) + { + case CatcherTrailAnimation.Dashing: + case CatcherTrailAnimation.HyperDashing: + this.FadeTo(0.4f).FadeOut(800, Easing.OutQuint); + break; + + case CatcherTrailAnimation.HyperDashAfterImage: + this.MoveToOffset(new Vector2(0, -10), 1200, Easing.In); + this.ScaleTo(Scale * 0.95f).ScaleTo(Scale * 1.2f, 1200, Easing.In); + this.FadeOut(1200); + break; + } + + Expire(); } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailAnimation.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailAnimation.cs new file mode 100644 index 0000000000..0a5281cd10 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailAnimation.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Catch.UI +{ + public enum CatcherTrailAnimation + { + Dashing, + HyperDashing, + HyperDashAfterImage + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs index 7e4a5b6a86..0f2530e56a 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; -using osuTK; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Rulesets.Objects.Pooling; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI @@ -15,70 +17,32 @@ namespace osu.Game.Rulesets.Catch.UI /// Represents a component responsible for displaying /// the appropriate catcher trails when requested to. /// - public class CatcherTrailDisplay : CompositeDrawable + public class CatcherTrailDisplay : PooledDrawableWithLifetimeContainer { - private readonly Catcher catcher; + /// + /// The most recent time a dash trail was added to this container. + /// Only alive (not faded out) trails are considered. + /// Returns if no dash trail is alive. + /// + public double LastDashTrailTime => getLastDashTrailTime(); + + public Color4 HyperDashTrailsColour => hyperDashTrails.Colour; + + public Color4 HyperDashAfterImageColour => hyperDashAfterImages.Colour; + + protected override bool RemoveRewoundEntry => true; private readonly DrawablePool trailPool; private readonly Container dashTrails; private readonly Container hyperDashTrails; - private readonly Container endGlowSprites; + private readonly Container hyperDashAfterImages; - private Color4 hyperDashTrailsColour = Catcher.DEFAULT_HYPER_DASH_COLOUR; + [Resolved] + private ISkinSource skin { get; set; } - public Color4 HyperDashTrailsColour + public CatcherTrailDisplay() { - get => hyperDashTrailsColour; - set - { - if (hyperDashTrailsColour == value) - return; - - hyperDashTrailsColour = value; - hyperDashTrails.Colour = hyperDashTrailsColour; - } - } - - private Color4 endGlowSpritesColour = Catcher.DEFAULT_HYPER_DASH_COLOUR; - - public Color4 EndGlowSpritesColour - { - get => endGlowSpritesColour; - set - { - if (endGlowSpritesColour == value) - return; - - endGlowSpritesColour = value; - endGlowSprites.Colour = endGlowSpritesColour; - } - } - - private bool trail; - - /// - /// Whether to start displaying trails following the catcher. - /// - public bool DisplayTrail - { - get => trail; - set - { - if (trail == value) - return; - - trail = value; - - if (trail) - displayTrail(); - } - } - - public CatcherTrailDisplay([NotNull] Catcher catcher) - { - this.catcher = catcher ?? throw new ArgumentNullException(nameof(catcher)); - RelativeSizeAxes = Axes.Both; InternalChildren = new Drawable[] @@ -86,47 +50,86 @@ namespace osu.Game.Rulesets.Catch.UI 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 }, + hyperDashAfterImages = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR }, }; } - /// - /// Displays a single end-glow catcher sprite. - /// - public void DisplayEndGlow() + protected override void LoadComplete() { - var endGlow = createTrailSprite(endGlowSprites); + base.LoadComplete(); - endGlow.MoveToOffset(new Vector2(0, -10), 1200, Easing.In); - endGlow.ScaleTo(endGlow.Scale * 0.95f).ScaleTo(endGlow.Scale * 1.2f, 1200, Easing.In); - endGlow.FadeOut(1200); - endGlow.Expire(true); + skin.SourceChanged += skinSourceChanged; + skinSourceChanged(); } - private void displayTrail() + private void skinSourceChanged() { - if (!DisplayTrail) - return; - - var sprite = createTrailSprite(catcher.HyperDashing ? hyperDashTrails : dashTrails); - - sprite.FadeTo(0.4f).FadeOut(800, Easing.OutQuint); - sprite.Expire(true); - - Scheduler.AddDelayed(displayTrail, catcher.HyperDashing ? 25 : 50); + hyperDashTrails.Colour = skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? Catcher.DEFAULT_HYPER_DASH_COLOUR; + hyperDashAfterImages.Colour = skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value ?? hyperDashTrails.Colour; } - private CatcherTrail createTrailSprite(Container target) + protected override void AddDrawable(CatcherTrailEntry entry, CatcherTrail drawable) { - CatcherTrail sprite = trailPool.Get(); + switch (entry.Animation) + { + case CatcherTrailAnimation.Dashing: + dashTrails.Add(drawable); + break; - sprite.AnimationState = catcher.CurrentState; - sprite.Scale = catcher.Scale; - sprite.Position = catcher.Position; + case CatcherTrailAnimation.HyperDashing: + hyperDashTrails.Add(drawable); + break; - target.Add(sprite); + case CatcherTrailAnimation.HyperDashAfterImage: + hyperDashAfterImages.Add(drawable); + break; + } + } - return sprite; + protected override void RemoveDrawable(CatcherTrailEntry entry, CatcherTrail drawable) + { + switch (entry.Animation) + { + case CatcherTrailAnimation.Dashing: + dashTrails.Remove(drawable); + break; + + case CatcherTrailAnimation.HyperDashing: + hyperDashTrails.Remove(drawable); + break; + + case CatcherTrailAnimation.HyperDashAfterImage: + hyperDashAfterImages.Remove(drawable); + break; + } + } + + protected override CatcherTrail GetDrawable(CatcherTrailEntry entry) + { + CatcherTrail trail = trailPool.Get(); + trail.Apply(entry); + return trail; + } + + private double getLastDashTrailTime() + { + double maxTime = double.NegativeInfinity; + + foreach (var trail in dashTrails) + maxTime = Math.Max(maxTime, trail.LifetimeStart); + + foreach (var trail in hyperDashTrails) + maxTime = Math.Max(maxTime, trail.LifetimeStart); + + return maxTime; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (skin != null) + skin.SourceChanged -= skinSourceChanged; } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailEntry.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailEntry.cs new file mode 100644 index 0000000000..3a40ab26cc --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailEntry.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Performance; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class CatcherTrailEntry : LifetimeEntry + { + public readonly CatcherAnimationState CatcherState; + + public readonly float Position; + + /// + /// The scaling of the catcher body. It also represents a flipped catcher (negative x component). + /// + public readonly Vector2 Scale; + + public readonly CatcherTrailAnimation Animation; + + public CatcherTrailEntry(double startTime, CatcherAnimationState catcherState, float position, Vector2 scale, CatcherTrailAnimation animation) + { + LifetimeStart = startTime; + CatcherState = catcherState; + Position = position; + Scale = scale; + Animation = animation; + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index 8b6a074426..a8ec9f1d2f 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -27,14 +27,14 @@ namespace osu.Game.Rulesets.Catch.UI : base(ruleset, beatmap, mods) { Direction.Value = ScrollingDirection.Down; - TimeRange.Value = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450); + TimeRange.Value = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450); } protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new CatchFramedReplayInputHandler(replay); protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield); - protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty); + protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.Difficulty); public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchPlayfieldAdjustmentContainer(); diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs index d9ab428231..955b1e6edb 100644 --- a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs @@ -1,129 +1,56 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Skinning.Default; using osu.Game.Rulesets.Objects.Pooling; -using osu.Game.Utils; -using osuTK; -using osuTK.Graphics; +using osu.Game.Skinning; + +#nullable enable namespace osu.Game.Rulesets.Catch.UI { public class HitExplosion : PoolableDrawableWithLifetime { - private readonly CircularContainer largeFaint; - private readonly CircularContainer smallFaint; - private readonly CircularContainer directionalGlow1; - private readonly CircularContainer directionalGlow2; + private readonly SkinnableDrawable skinnableExplosion; public HitExplosion() { - Size = new Vector2(20); - Anchor = Anchor.TopCentre; + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.BottomCentre; Origin = Anchor.BottomCentre; - // scale roughly in-line with visual appearance of notes - const float initial_height = 10; - - InternalChildren = new Drawable[] + InternalChild = skinnableExplosion = new SkinnableDrawable(new CatchSkinComponent(CatchSkinComponents.HitExplosion), _ => new DefaultHitExplosion()) { - largeFaint = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - Blending = BlendingParameters.Additive, - }, - smallFaint = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - Blending = BlendingParameters.Additive, - }, - directionalGlow1 = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - Size = new Vector2(0.01f, initial_height), - Blending = BlendingParameters.Additive, - }, - directionalGlow2 = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - Size = new Vector2(0.01f, initial_height), - Blending = BlendingParameters.Additive, - } + CentreComponent = false, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre }; } protected override void OnApply(HitExplosionEntry entry) { - X = entry.Position; - Scale = new Vector2(entry.Scale); - setColour(entry.ObjectColour); - - using (BeginAbsoluteSequence(entry.LifetimeStart)) - applyTransforms(entry.RNGSeed); + base.OnApply(entry); + if (IsLoaded) + apply(entry); } - private void applyTransforms(int randomSeed) + protected override void LoadComplete() { + base.LoadComplete(); + apply(Entry); + } + + private void apply(HitExplosionEntry? entry) + { + if (entry == null) + return; + + ApplyTransformsAt(double.MinValue, true); ClearTransforms(true); - const double duration = 400; - - // we want our size to be very small so the glow dominates it. - largeFaint.Size = new Vector2(0.8f); - largeFaint - .ResizeTo(largeFaint.Size * new Vector2(5, 1), duration, Easing.OutQuint) - .FadeOut(duration * 2); - - const float angle_variangle = 15; // should be less than 45 - 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(); - } - - private void setColour(Color4 objectColour) - { - const float roundness = 100; - - largeFaint.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.1f, objectColour, Color4.White, 0, 1).Opacity(0.3f), - Roundness = 160, - Radius = 200, - }; - - smallFaint.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.6f, objectColour, Color4.White, 0, 1), - Roundness = 20, - Radius = 50, - }; - - directionalGlow1.EdgeEffect = directionalGlow2.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.4f, objectColour, Color4.White, 0, 1), - Roundness = roundness, - Radius = 40, - }; + (skinnableExplosion.Drawable as IHitExplosion)?.Animate(entry); + LifetimeEnd = skinnableExplosion.Drawable.LatestTransformEndTime; } } } diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs b/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs index 094d88243a..6df13e52ef 100644 --- a/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.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.Graphics; using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Objects.Pooling; @@ -14,6 +15,8 @@ namespace osu.Game.Rulesets.Catch.UI public HitExplosionContainer() { + RelativeSizeAxes = Axes.Both; + AddInternal(pool = new DrawablePool(10)); } diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs b/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs index b142962a8a..88871c77f6 100644 --- a/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs @@ -2,24 +2,42 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Performance; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Judgements; using osuTK.Graphics; +#nullable enable + 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; + /// + /// The judgement result that triggered this explosion. + /// + public JudgementResult JudgementResult { get; } - public HitExplosionEntry(double startTime, float position, float scale, Color4 objectColour, int rngSeed) + /// + /// The hitobject which triggered this explosion. + /// + public CatchHitObject HitObject => (CatchHitObject)JudgementResult.HitObject; + + /// + /// The accent colour of the object caught. + /// + public Color4 ObjectColour { get; } + + /// + /// The position at which the object was caught. + /// + public float Position { get; } + + public HitExplosionEntry(double startTime, JudgementResult judgementResult, Color4 objectColour, float position) { LifetimeStart = startTime; Position = position; - Scale = scale; + JudgementResult = judgementResult; ObjectColour = objectColour; - RNGSeed = rngSeed; } } } diff --git a/osu.Game.Rulesets.Catch/UI/IHitExplosion.cs b/osu.Game.Rulesets.Catch/UI/IHitExplosion.cs new file mode 100644 index 0000000000..c744c00d9a --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/IHitExplosion.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. + +#nullable enable + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// Common interface for all hit explosion skinnables. + /// + public interface IHitExplosion + { + /// + /// Begins animating this . + /// + void Animate(HitExplosionEntry entry); + } +} diff --git a/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs index fc34ba4c8b..8d707a4beb 100644 --- a/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs +++ b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Catch.UI { 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; + OriginPosition = new Vector2(0.5f, 0.06f) * Catcher.BASE_SIZE; } } } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs new file mode 100644 index 0000000000..0f520215a1 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.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; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Editor +{ + public class TestSceneManiaComposeScreen : EditorClockTestScene + { + [Resolved] + private SkinManager skins { get; set; } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("setup compose screen", () => + { + var editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 })) + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + }; + + Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(EditorBeatmap), editorBeatmap), + (typeof(IBeatSnapProvider), editorBeatmap), + }, + Child = new ComposeScreen { State = { Value = Visibility.Visible } }, + }; + }); + + AddUntilStep("wait for composer", () => this.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); + } + + [Test] + public void TestDefaultSkin() + { + AddStep("set default skin", () => skins.CurrentSkinInfo.Value = SkinInfo.Default); + } + + [Test] + public void TestLegacySkin() + { + AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.Info); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1-0@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1-0@2x.png new file mode 100644 index 0000000000..2db5d76e78 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1-0@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1-1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1-1@2x.png new file mode 100644 index 0000000000..6e7aded39f Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1-1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1H-0@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1H-0@2x.png new file mode 100644 index 0000000000..f11fb4f853 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1H-0@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1H-1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1H-1@2x.png new file mode 100644 index 0000000000..4eac5f6f2a Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note1H-1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2-0@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2-0@2x.png new file mode 100644 index 0000000000..456cee5382 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2-0@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2-1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2-1@2x.png new file mode 100644 index 0000000000..71a09cb4bb Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2-1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2H@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2H@2x.png new file mode 100644 index 0000000000..e6da7a1055 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-note2H@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-noteS@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-noteS@2x.png new file mode 100644 index 0000000000..c9bc23e8d9 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-noteS@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-noteSH@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-noteSH@2x.png new file mode 100644 index 0000000000..c9bc23e8d9 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-noteSH@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs index b7d7af6b8c..68cf3b67df 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { c.Add(CreateHitObject().With(h => { - h.HitObject.StartTime = START_TIME; + h.HitObject.StartTime = Time.Current + 5000; h.AccentColour.Value = Color4.Orange; })); }) @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { c.Add(CreateHitObject().With(h => { - h.HitObject.StartTime = START_TIME; + h.HitObject.StartTime = Time.Current + 5000; h.AccentColour.Value = Color4.Orange; })); }) diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs index 1d84a2dfcb..ddfd057cd8 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs @@ -19,8 +19,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning /// public abstract class ManiaSkinnableTestScene : SkinnableTestScene { - protected const double START_TIME = 1000000000; - [Cached(Type = typeof(IScrollingInfo))] private readonly TestScrollingInfo scrollingInfo = new TestScrollingInfo(); @@ -55,27 +53,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning public readonly Bindable Direction = new Bindable(); IBindable IScrollingInfo.Direction => Direction; - IBindable IScrollingInfo.TimeRange { get; } = new Bindable(1000); - IScrollAlgorithm IScrollingInfo.Algorithm { get; } = new ZeroScrollAlgorithm(); - } - - private class ZeroScrollAlgorithm : IScrollAlgorithm - { - public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) - => double.MinValue; - - public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) - => scrollLength; - - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) - => (float)((time - START_TIME) / timeRange) * scrollLength; - - public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) - => 0; - - public void Reset() - { - } + IBindable IScrollingInfo.TimeRange { get; } = new Bindable(5000); + IScrollAlgorithm IScrollingInfo.Algorithm { get; } = new ConstantScrollAlgorithm(); } } } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs index 4a6c59e297..92c95b8fde 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -58,7 +59,7 @@ namespace osu.Game.Rulesets.Mania.Tests AddStep("Hold key", () => { clock.CurrentTime = 0; - note.OnPressed(ManiaAction.Key1); + note.OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, ManiaAction.Key1)); }); AddStep("progress time", () => clock.CurrentTime = 500); AddAssert("head is visible", () => note.Head.Alpha == 1); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs index e14ad92842..449a6ff23d 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.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.Graphics; using osu.Framework.Allocation; @@ -13,6 +14,10 @@ using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Configuration; using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Tests { @@ -22,14 +27,65 @@ namespace osu.Game.Rulesets.Mania.Tests [Resolved] private RulesetConfigCache configCache { get; set; } - private readonly Bindable configTimingBasedNoteColouring = new Bindable(); + private Bindable configTimingBasedNoteColouring; - protected override void LoadComplete() + private ManualClock clock; + private DrawableManiaRuleset drawableRuleset; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("setup hierarchy", () => Child = new Container + { + Clock = new FramedClock(clock = new ManualClock()), + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + drawableRuleset = (DrawableManiaRuleset)Ruleset.Value.CreateInstance().CreateDrawableRulesetWith(createTestBeatmap()) + } + }); + AddStep("retrieve config bindable", () => + { + var config = (ManiaRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); + configTimingBasedNoteColouring = config.GetBindable(ManiaRulesetSetting.TimingBasedNoteColouring); + }); + } + + [Test] + public void TestSimple() + { + AddStep("enable", () => configTimingBasedNoteColouring.Value = true); + AddStep("disable", () => configTimingBasedNoteColouring.Value = false); + } + + [Test] + public void TestToggleOffScreen() + { + AddStep("enable", () => configTimingBasedNoteColouring.Value = true); + + seekTo(10000); + AddStep("disable", () => configTimingBasedNoteColouring.Value = false); + seekTo(0); + AddAssert("all notes not coloured", () => this.ChildrenOfType().All(note => note.Colour == Colour4.White)); + + seekTo(10000); + AddStep("enable again", () => configTimingBasedNoteColouring.Value = true); + seekTo(0); + AddAssert("some notes coloured", () => this.ChildrenOfType().Any(note => note.Colour != Colour4.White)); + } + + private void seekTo(double time) + { + AddStep($"seek to {time}", () => clock.CurrentTime = time); + AddUntilStep("wait for seek", () => Precision.AlmostEquals(drawableRuleset.FrameStableClock.CurrentTime, time, 1)); + } + + private ManiaBeatmap createTestBeatmap() { const double beat_length = 500; - var ruleset = new ManiaRuleset(); - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }) { HitObjects = @@ -45,7 +101,7 @@ namespace osu.Game.Rulesets.Mania.Tests new Note { StartTime = beat_length } }, ControlPointInfo = new ControlPointInfo(), - BeatmapInfo = { Ruleset = ruleset.RulesetInfo }, + BeatmapInfo = { Ruleset = Ruleset.Value }, }; foreach (var note in beatmap.HitObjects) @@ -57,24 +113,7 @@ namespace osu.Game.Rulesets.Mania.Tests { BeatLength = beat_length }); - - Child = new Container - { - Clock = new FramedClock(new ManualClock()), - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new[] - { - ruleset.CreateDrawableRulesetWith(beatmap) - } - }; - - var config = (ManiaRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); - config.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring); - - AddStep("Enable", () => configTimingBasedNoteColouring.Value = true); - AddStep("Disable", () => configTimingBasedNoteColouring.Value = false); + return beatmap; } } } 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 6df555617b..674a22df98 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index 26393c8edb..ebfbaccd31 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -42,8 +42,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps { IsForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo); - var roundedCircleSize = Math.Round(beatmap.BeatmapInfo.BaseDifficulty.CircleSize); - var roundedOverallDifficulty = Math.Round(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + var roundedCircleSize = Math.Round(beatmap.Difficulty.CircleSize); + var roundedOverallDifficulty = Math.Round(beatmap.Difficulty.OverallDifficulty); if (IsForCurrentRuleset) { @@ -71,9 +71,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps originalTargetColumns = TargetColumns; } - public static int GetColumnCountForNonConvert(BeatmapInfo beatmap) + public static int GetColumnCountForNonConvert(BeatmapInfo beatmapInfo) { - var roundedCircleSize = Math.Round(beatmap.BaseDifficulty.CircleSize); + var roundedCircleSize = Math.Round(beatmapInfo.BaseDifficulty.CircleSize); return (int)Math.Max(1, roundedCircleSize); } @@ -81,7 +81,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps protected override Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) { - BeatmapDifficulty difficulty = original.BeatmapInfo.BaseDifficulty; + IBeatmapDifficultyInfo difficulty = original.Difficulty; int seed = (int)MathF.Round(difficulty.DrainRate + difficulty.CircleSize) * 20 + (int)(difficulty.OverallDifficulty * 41.2) + (int)MathF.Round(difficulty.ApproachRate); Random = new FastRandom(seed); diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index 26e5d381e2..380efff69f 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -61,7 +61,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy StartTime = (int)Math.Round(hitObject.StartTime); // This matches stable's calculation. - EndTime = (int)Math.Floor(StartTime + distanceData.Distance * beatLength * SpanCount * 0.01 / beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier); + EndTime = (int)Math.Floor(StartTime + distanceData.Distance * beatLength * SpanCount * 0.01 / beatmap.Difficulty.SliderMultiplier); SegmentDuration = (EndTime - StartTime) / SpanCount; } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs index fb58d805a9..eaf0ea0f2b 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs @@ -111,7 +111,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (drainTime == 0) drainTime = 10000; - BeatmapDifficulty difficulty = OriginalBeatmap.BeatmapInfo.BaseDifficulty; + IBeatmapDifficultyInfo difficulty = OriginalBeatmap.Difficulty; conversionDifficulty = ((difficulty.DrainRate + Math.Clamp(difficulty.ApproachRate, 4, 7)) / 1.5 + (double)OriginalBeatmap.HitObjects.Count / drainTime * 9f) / 38f * 5f / 1.15; conversionDifficulty = Math.Min(conversionDifficulty.Value, 12); @@ -149,7 +149,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { lowerBound ??= RandomStart; upperBound ??= TotalColumns; - nextColumn ??= (_ => GetRandomColumn(lowerBound, upperBound)); + nextColumn ??= _ => GetRandomColumn(lowerBound, upperBound); // Check for the initial column if (isValid(initialColumn)) @@ -176,7 +176,19 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy return initialColumn; - bool isValid(int column) => validation?.Invoke(column) != false && !patterns.Any(p => p.ColumnHasObject(column)); + bool isValid(int column) + { + if (validation?.Invoke(column) == false) + return false; + + foreach (var p in patterns) + { + if (p.ColumnHasObject(column)) + return false; + } + + return true; + } } /// diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs index f095a0ffce..828f2ec393 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs @@ -12,46 +12,68 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns /// internal class Pattern { - private readonly List hitObjects = new List(); + private List hitObjects; + private HashSet containedColumns; /// /// All the hit objects contained in this pattern. /// - public IEnumerable HitObjects => hitObjects; + public IEnumerable HitObjects => hitObjects ?? Enumerable.Empty(); /// /// Check whether a column of this patterns contains a hit object. /// /// The column index. /// Whether the column with index contains a hit object. - public bool ColumnHasObject(int column) => hitObjects.Exists(h => h.Column == column); + public bool ColumnHasObject(int column) => containedColumns?.Contains(column) == true; /// /// Amount of columns taken up by hit objects in this pattern. /// - public int ColumnWithObjects => HitObjects.GroupBy(h => h.Column).Count(); + public int ColumnWithObjects => containedColumns?.Count ?? 0; /// /// Adds a hit object to this pattern. /// /// The hit object to add. - public void Add(ManiaHitObject hitObject) => hitObjects.Add(hitObject); + public void Add(ManiaHitObject hitObject) + { + prepareStorage(); + + hitObjects.Add(hitObject); + containedColumns.Add(hitObject.Column); + } /// /// Copies hit object from another pattern to this one. /// /// The other pattern. - public void Add(Pattern other) => hitObjects.AddRange(other.HitObjects); + public void Add(Pattern other) + { + prepareStorage(); + + if (other.hitObjects != null) + { + hitObjects.AddRange(other.hitObjects); + + foreach (var h in other.hitObjects) + containedColumns.Add(h.Column); + } + } /// /// Clears this pattern, removing all hit objects. /// - public void Clear() => hitObjects.Clear(); + public void Clear() + { + hitObjects?.Clear(); + containedColumns?.Clear(); + } - /// - /// Removes a hit object from this pattern. - /// - /// The hit object to remove. - public bool Remove(ManiaHitObject hitObject) => hitObjects.Remove(hitObject); + private void prepareStorage() + { + hitObjects ??= new List(); + containedColumns ??= new HashSet(); + } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index a7a6677b68..aee3268544 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -41,15 +41,14 @@ namespace osu.Game.Rulesets.Mania.Difficulty return new ManiaDifficultyAttributes { Mods = mods, Skills = skills }; HitWindows hitWindows = new ManiaHitWindows(); - hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); return new ManiaDifficultyAttributes { StarRating = skills[0].DifficultyValue() * star_scaling_factor, Mods = mods, - // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future - GreatHitWindow = (int)Math.Ceiling(getHitWindow300(mods) / clockRate), - ScoreMultiplier = getScoreMultiplier(beatmap, mods), + GreatHitWindow = Math.Ceiling(getHitWindow300(mods) / clockRate), + ScoreMultiplier = getScoreMultiplier(mods), MaxCombo = beatmap.HitObjects.Sum(h => h is HoldNote ? 2 : 1), Skills = skills }; @@ -70,7 +69,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { - new Strain(mods, ((ManiaBeatmap)beatmap).TotalColumns) + new Strain(mods, ((ManiaBeatmap)Beatmap).TotalColumns) }; protected override Mod[] DifficultyAdjustmentMods @@ -138,7 +137,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty } } - private double getScoreMultiplier(IBeatmap beatmap, Mod[] mods) + private double getScoreMultiplier(Mod[] mods) { double scoreMultiplier = 1; @@ -154,7 +153,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty } } - var maniaBeatmap = (ManiaBeatmap)beatmap; + var maniaBeatmap = (ManiaBeatmap)Beatmap; int diff = maniaBeatmap.TotalColumns - maniaBeatmap.OriginalTotalColumns; if (diff > 0) diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index 405ac56e94..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,12 +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); + 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/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index 2ba2ee6b4a..01d930d585 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Difficulty.Skills { - public class Strain : StrainSkill + public class Strain : StrainDecaySkill { private const double individual_decay_base = 0.125; private const double overall_decay_base = 0.30; @@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills return individualStrain + overallStrain - CurrentStrain; } - protected override double GetPeakStrain(double offset) + protected override double CalculateInitialStrain(double offset) => applyDecay(individualStrain, offset - Previous[0].StartTime, individual_decay_base) + applyDecay(overallStrain, offset - Previous[0].StartTime, overall_decay_base); diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs new file mode 100644 index 0000000000..a206aafb8a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Screens.Edit.Setup; + +namespace osu.Game.Rulesets.Mania.Edit.Setup +{ + public class ManiaSetupSection : RulesetSetupSection + { + private LabelledSwitchButton specialStyle; + + public ManiaSetupSection() + : base(new ManiaRuleset().RulesetInfo) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + specialStyle = new LabelledSwitchButton + { + Label = "Use special (N+1) style", + Description = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 5k (4+1) or 8key (7+1) configurations.", + Current = { Value = Beatmap.BeatmapInfo.SpecialStyle } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + specialStyle.Current.BindValueChanged(_ => updateBeatmap()); + } + + private void updateBeatmap() + { + Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value; + } + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs index d9a278ef29..0290230490 100644 --- a/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs +++ b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs @@ -13,9 +13,9 @@ namespace osu.Game.Rulesets.Mania { private FilterCriteria.OptionalRange keys; - public bool Matches(BeatmapInfo beatmap) + public bool Matches(BeatmapInfo beatmapInfo) { - return !keys.HasFilter || (beatmap.RulesetID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmap))); + return !keys.HasFilter || (beatmapInfo.RulesetID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmapInfo))); } public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index fe736766d9..1f79dae280 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -27,11 +27,13 @@ using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Difficulty; using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Mania.Edit.Setup; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mania.Skinning.Legacy; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osu.Game.Scoring; +using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets.Mania @@ -253,7 +255,8 @@ namespace osu.Game.Rulesets.Mania case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()), + new ManiaModMuted(), }; default: @@ -389,6 +392,8 @@ namespace osu.Game.Rulesets.Mania { return new ManiaFilterCriteria(); } + + public override RulesetSetupSection CreateEditorSetupSection() => new ManiaSetupSection(); } public enum PlayfieldType diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index f89750a96e..36fa336d0c 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -13,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) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs index cf404cc98e..f01884c97f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs @@ -10,13 +10,9 @@ using osu.Game.Rulesets.Mania.Beatmaps; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModMirror : Mod, IApplicableToBeatmap + public class ManiaModMirror : ModMirror, IApplicableToBeatmap { - public override string Name => "Mirror"; - public override string Acronym => "MR"; - public override ModType Type => ModType.Conversion; public override string Description => "Notes are flipped horizontally."; - public override double ScoreMultiplier => 1; public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.cs new file mode 100644 index 0000000000..33ebcf303a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModMuted : ModMuted + { + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index d1310d42eb..4e9781f336 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -253,12 +254,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables HoldBrokenTime = Time.Current; } - public bool OnPressed(ManiaAction action) + public bool OnPressed(KeyBindingPressEvent e) { if (AllJudged) return false; - if (action != Action.Value) + if (e.Action != Action.Value) return false; // do not run any of this logic when rewinding, as it inverts order of presses/releases. @@ -275,9 +276,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables return false; beginHoldAt(Time.Current - Head.HitObject.StartTime); - Head.UpdateResult(); - return true; + return Head.UpdateResult(); } private void beginHoldAt(double timeOffset) @@ -289,12 +289,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables isHitting.Value = true; } - public void OnReleased(ManiaAction action) + public void OnReleased(KeyBindingReleaseEvent e) { if (AllJudged) return; - if (action != Action.Value) + if (e.Action != Action.Value) return; // do not run any of this logic when rewinding, as it inverts order of presses/releases. diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs index be600f0d47..6722ad8ab8 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Mania.Objects.Drawables @@ -25,7 +26,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Origin = Anchor.TopCentre; } - public void UpdateResult() => base.UpdateResult(true); + public bool UpdateResult() => base.UpdateResult(true); protected override void UpdateInitialTransforms() { @@ -43,9 +44,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables // it will be hidden along with its parenting hold note when required. } - public override bool OnPressed(ManiaAction action) => false; // Handled by the hold note + public override bool OnPressed(KeyBindingPressEvent e) => false; // Handled by the hold note - public override void OnReleased(ManiaAction action) + public override void OnReleased(KeyBindingReleaseEvent e) { } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index 18aa3f66d4..803685363c 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Objects.Drawables @@ -68,9 +69,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }); } - public override bool OnPressed(ManiaAction action) => false; // Handled by the hold note + public override bool OnPressed(KeyBindingPressEvent e) => false; // Handled by the hold note - public override void OnReleased(ManiaAction action) + public override void OnReleased(KeyBindingReleaseEvent e) { } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 3ec68bfb56..9ac223a0d7 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -6,7 +6,6 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.Mania.UI; @@ -22,14 +21,13 @@ 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; } - /// - /// Gets the samples that are played by this object during gameplay. - /// - public ISampleInfo[] GetGameplaySamples() => Samples.Samples; - protected override float SamplePlaybackPosition { get diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs index 33d872dfb6..51727908c9 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Configuration; @@ -66,6 +67,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables StartTimeBindable.BindValueChanged(_ => updateSnapColour(), true); } + protected override void OnApply() + { + base.OnApply(); + updateSnapColour(); + } + protected override void OnDirectionChanged(ValueChangedEvent e) { base.OnDirectionChanged(e); @@ -91,9 +98,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables ApplyResult(r => r.Type = result); } - public virtual bool OnPressed(ManiaAction action) + public virtual bool OnPressed(KeyBindingPressEvent e) { - if (action != Action.Value) + if (e.Action != Action.Value) return false; if (CheckHittable?.Invoke(this, Time.Current) == false) @@ -102,7 +109,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables return UpdateResult(true); } - public virtual void OnReleased(ManiaAction action) + public virtual void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs index 43e876b7aa..c1937af7e4 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs @@ -84,7 +84,7 @@ namespace osu.Game.Rulesets.Mania.Objects /// private double tickSpacing = 50; - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyColumnBackground.cs index 661e7f66f4..54ddcbd5fe 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyColumnBackground.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; @@ -76,9 +77,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy } } - public bool OnPressed(ManiaAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action == Column.Action.Value) + if (e.Action == Column.Action.Value) { light.FadeIn(); light.ScaleTo(Vector2.One); @@ -87,12 +88,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return false; } - public void OnReleased(ManiaAction action) + public void OnReleased(KeyBindingReleaseEvent e) { // Todo: Should be 400 * 100 / CurrentBPM const double animation_length = 250; - if (action == Column.Action.Value) + if (e.Action == Column.Action.Value) { light.FadeTo(0, animation_length); light.ScaleTo(new Vector2(1, 0), animation_length); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteHeadPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteHeadPiece.cs index 21e5bdd5d6..1e75533442 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteHeadPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteHeadPiece.cs @@ -1,18 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { public class LegacyHoldNoteHeadPiece : LegacyNotePiece { - protected override Texture GetTexture(ISkinSource skin) + protected override Drawable GetAnimation(ISkinSource skin) { // TODO: Should fallback to the head from default legacy skin instead of note. - return GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) - ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + return GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) + ?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteTailPiece.cs index 232b47ae27..e6d4291d79 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteTailPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteTailPiece.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; @@ -18,12 +18,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy : new ValueChangedEvent(ScrollingDirection.Up, ScrollingDirection.Up)); } - protected override Texture GetTexture(ISkinSource skin) + protected override Drawable GetAnimation(ISkinSource skin) { // TODO: Should fallback to the head from default legacy skin instead of note. - return GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteTailImage) - ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) - ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + return GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteTailImage) + ?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) + ?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs index 10319a7d4d..9c339345c4 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; @@ -86,9 +87,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy } } - public bool OnPressed(ManiaAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action == column.Action.Value) + if (e.Action == column.Action.Value) { upSprite.FadeTo(0); downSprite.FadeTo(1); @@ -97,9 +98,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return false; } - public void OnReleased(ManiaAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - if (action == column.Action.Value) + if (e.Action == column.Action.Value) { upSprite.Delay(LegacyHitExplosion.FADE_IN_DURATION).FadeTo(1); downSprite.Delay(LegacyHitExplosion.FADE_IN_DURATION).FadeTo(0); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs index 31279796ce..321a87f8b1 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.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 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.OpenGL.Textures; using osu.Framework.Graphics.Sprites; @@ -19,7 +21,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy private readonly IBindable direction = new Bindable(); private Container directionContainer; - private Sprite noteSprite; + + [CanBeNull] + private Drawable noteAnimation; private float? minimumColumnWidth; @@ -39,7 +43,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Child = noteSprite = new Sprite { Texture = GetTexture(skin) } + Child = noteAnimation = GetAnimation(skin) ?? Empty() }; direction.BindTo(scrollingInfo.Direction); @@ -50,12 +54,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { base.Update(); - if (noteSprite.Texture != null) + Texture texture = null; + + if (noteAnimation is Sprite sprite) + texture = sprite.Texture; + else if (noteAnimation is TextureAnimation textureAnimation && textureAnimation.FrameCount > 0) + texture = textureAnimation.CurrentFrame; + + if (texture != null) { // The height is scaled to the minimum column width, if provided. float minimumWidth = minimumColumnWidth ?? DrawWidth; - - noteSprite.Scale = Vector2.Divide(new Vector2(DrawWidth, minimumWidth), noteSprite.Texture.DisplayWidth); + noteAnimation.Scale = Vector2.Divide(new Vector2(DrawWidth, minimumWidth), texture.DisplayWidth); } } @@ -73,9 +83,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy } } - protected virtual Texture GetTexture(ISkinSource skin) => GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + [CanBeNull] + protected virtual Drawable GetAnimation(ISkinSource skin) => GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); - protected Texture GetTextureFromLookup(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) + [CanBeNull] + protected Drawable GetAnimationFromLookup(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) { string suffix = string.Empty; @@ -93,7 +105,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy string noteImage = GetColumnSkinConfig(skin, lookup)?.Value ?? $"mania-note{FallbackColumnIndex}{suffix}"; - return skin.GetTexture(noteImage, WrapMode.ClampToEdge, WrapMode.ClampToEdge); + return skin.GetAnimation(noteImage, WrapMode.ClampToEdge, WrapMode.ClampToEdge, true, true); } } } diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 9b5893b268..9d060944cd 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.UI.Scrolling; @@ -19,6 +19,7 @@ using osuTK; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania.UI { @@ -28,12 +29,6 @@ namespace osu.Game.Rulesets.Mania.UI public const float COLUMN_WIDTH = 80; public const float SPECIAL_COLUMN_WIDTH = 70; - /// - /// For hitsounds played by this (i.e. not as a result of hitting a hitobject), - /// a certain number of samples are allowed to be played concurrently so that it feels better when spam-pressing the key. - /// - private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY; - /// /// The index of this column as part of the whole playfield. /// @@ -45,10 +40,10 @@ namespace osu.Game.Rulesets.Mania.UI internal readonly Container TopLevelContainer; private readonly DrawablePool hitExplosionPool; private readonly OrderedHitPolicy hitPolicy; - private readonly Container hitSounds; - public Container UnderlayElements => HitObjectArea.UnderlayElements; + private readonly GameplaySampleTriggerSource sampleTriggerSource; + public Column(int index) { Index = index; @@ -64,6 +59,7 @@ namespace osu.Game.Rulesets.Mania.UI InternalChildren = new[] { hitExplosionPool = new DrawablePool(5), + sampleTriggerSource = new GameplaySampleTriggerSource(HitObjectContainer), // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both }, @@ -72,12 +68,6 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Both }, background, - hitSounds = new Container - { - Name = "Column samples pool", - RelativeSizeAxes = Axes.Both, - Children = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound()).ToArray() - }, TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both } }; @@ -133,33 +123,16 @@ namespace osu.Game.Rulesets.Mania.UI HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result))); } - private int nextHitSoundIndex; - - public bool OnPressed(ManiaAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action != Action.Value) + if (e.Action != Action.Value) return false; - var nextObject = - HitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ?? - // fallback to non-alive objects to find next off-screen object - HitObjectContainer.Objects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ?? - HitObjectContainer.Objects.LastOrDefault(); - - if (nextObject is DrawableManiaHitObject maniaObject) - { - var hitSound = hitSounds[nextHitSoundIndex]; - - hitSound.Samples = maniaObject.GetGameplaySamples(); - hitSound.Play(); - - nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds; - } - + sampleTriggerSource.Play(); return true; } - public void OnReleased(ManiaAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs index 75cc351310..77ddc6fbbf 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Rulesets.UI.Scrolling; using osuTK.Graphics; @@ -91,16 +92,16 @@ namespace osu.Game.Rulesets.Mania.UI.Components direction.Value == ScrollingDirection.Up ? dimPoint : brightPoint); } - public bool OnPressed(ManiaAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action == this.action.Value) + if (e.Action == action.Value) backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); return false; } - public void OnReleased(ManiaAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - if (action == this.action.Value) + if (e.Action == action.Value) backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs index 4b4bc157d5..807f6a77d9 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Rulesets.UI.Scrolling; using osuTK.Graphics; @@ -74,16 +75,16 @@ namespace osu.Game.Rulesets.Mania.UI.Components } } - public bool OnPressed(ManiaAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action == column.Action.Value) + if (e.Action == column.Action.Value) backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); return false; } - public void OnReleased(ManiaAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - if (action == column.Action.Value) + if (e.Action == column.Action.Value) backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs index 47cb9bd45a..267ed1f5f4 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Rulesets.UI.Scrolling; using osuTK; using osuTK.Graphics; @@ -101,16 +102,16 @@ namespace osu.Game.Rulesets.Mania.UI.Components } } - public bool OnPressed(ManiaAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action == column.Action.Value) + if (e.Action == column.Action.Value) keyIcon.ScaleTo(1.4f, 50, Easing.OutQuint).Then().ScaleTo(1.3f, 250, Easing.OutQuint); return false; } - public void OnReleased(ManiaAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - if (action == column.Action.Value) + if (e.Action == column.Action.Value) keyIcon.ScaleTo(1f, 125, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index 34d972e60f..8581f016b1 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -37,12 +37,11 @@ namespace osu.Game.Rulesets.Mania.UI public override void PlayAnimation() { - base.PlayAnimation(); - switch (Result) { case HitResult.None: case HitResult.Miss: + base.PlayAnimation(); break; default: @@ -52,6 +51,8 @@ namespace osu.Game.Rulesets.Mania.UI this.Delay(50) .ScaleTo(0.75f, 250) .FadeOut(200); + + // osu!mania uses a custom fade length, so the base call is intentionally omitted. break; } } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index e497646a13..3b7da8d9ba 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); @@ -90,7 +90,7 @@ namespace osu.Game.Rulesets.Mania.UI { // Mania doesn't care about global velocity p.Velocity = 1; - p.BaseBeatLength *= Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier; + p.BaseBeatLength *= Beatmap.Difficulty.SliderMultiplier; // For non-mania beatmap, speed changes should only happen through timing points if (!isForCurrentRuleset) @@ -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/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..787807a8ea --- /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 IBeatmapDifficultyInfo 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, IBeatmapDifficultyInfo beatmapDifficulty) + { + Assert.That(check.Run(getContext(hitObjects, beatmapDifficulty)), Is.Empty); + } + + private void assertVeryShort(List hitObjects, IBeatmapDifficultyInfo 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, IBeatmapDifficultyInfo 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, IBeatmapDifficultyInfo beatmapDifficulty) + { + var beatmap = new Beatmap + { + HitObjects = hitObjects, + BeatmapInfo = new BeatmapInfo { BaseDifficulty = new BeatmapDifficulty(beatmapDifficulty) } + }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs index 9af2a99470..851be2b2f2 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [SetUp] public void Setup() => Schedule(() => { - editorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1; + editorBeatmap.Difficulty.SliderMultiplier = 1; editorBeatmap.ControlPointInfo.Clear(); editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length }); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs new file mode 100644 index 0000000000..47b2d3a098 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.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.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneOsuEditorGrids : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestGridExclusivity() + { + AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + rectangularGridActive(false); + + AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + rectangularGridActive(true); + + AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + rectangularGridActive(false); + } + + private void rectangularGridActive(bool active) + { + AddStep("choose placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move cursor to (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(new Vector2(1, 1))); + }); + + if (active) + AddAssert("placement blueprint at (0, 0)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(0, 0))); + else + AddAssert("placement blueprint at (1, 1)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(1, 1))); + + AddStep("choose selection tool", () => InputManager.Key(Key.Number1)); + } + + [Test] + public void TestGridSizeToggling() + { + AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); + AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any()); + gridSizeIs(4); + + nextGridSizeIs(8); + nextGridSizeIs(16); + nextGridSizeIs(32); + nextGridSizeIs(4); + } + + private void nextGridSizeIs(int size) + { + AddStep("toggle to next grid size", () => InputManager.Key(Key.G)); + gridSizeIs(size); + } + + private void gridSizeIs(int size) + => AddAssert($"grid size is {size}", () => this.ChildrenOfType().Single().Spacing == new Vector2(size) + && EditorBeatmap.BeatmapInfo.GridSize == size); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs index 35b79aa8ac..5a1aa42ed1 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddAssert("last connection displayed", () => { - var lastConnection = visualiser.Connections.Last(c => c.ControlPoint.Position.Value == new Vector2(300)); + var lastConnection = visualiser.Connections.Last(c => c.ControlPoint.Position == new Vector2(300)); return lastConnection.DrawWidth > 50; }); } @@ -166,14 +166,14 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { AddStep($"move mouse to control point {index}", () => { - Vector2 position = slider.Path.ControlPoints[index].Position.Value; + Vector2 position = slider.Path.ControlPoints[index].Position; InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position)); }); } private void assertControlPointPathType(int controlPointIndex, PathType? type) { - AddAssert($"point {controlPointIndex} is {type}", () => slider.Path.ControlPoints[controlPointIndex].Type.Value == type); + AddAssert($"point {controlPointIndex} is {type}", () => slider.Path.ControlPoints[controlPointIndex].Type == type); } private void addContextMenuItemStep(string contextMenuText) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs index 24b947c854..6bfe7f892b 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs @@ -108,9 +108,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [Test] public void TestDragControlPointPathAfterChangingType() { - AddStep("change type to bezier", () => slider.Path.ControlPoints[2].Type.Value = PathType.Bezier); + AddStep("change type to bezier", () => slider.Path.ControlPoints[2].Type = PathType.Bezier); AddStep("add point", () => slider.Path.ControlPoints.Add(new PathControlPoint(new Vector2(500, 10)))); - AddStep("change type to perfect", () => slider.Path.ControlPoints[3].Type.Value = PathType.PerfectCurve); + AddStep("change type to perfect", () => slider.Path.ControlPoints[3].Type = PathType.PerfectCurve); moveMouseToControlPoint(4); AddStep("hold", () => InputManager.PressButton(MouseButton.Left)); @@ -137,15 +137,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { AddStep($"move mouse to control point {index}", () => { - Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position.Value; + Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position; InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); }); } - private void assertControlPointType(int index, PathType type) => AddAssert($"control point {index} is {type}", () => slider.Path.ControlPoints[index].Type.Value == type); + private void assertControlPointType(int index, PathType type) => AddAssert($"control point {index} is {type}", () => slider.Path.ControlPoints[index].Type == type); private void assertControlPointPosition(int index, Vector2 position) => - AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, slider.Path.ControlPoints[index].Position.Value, 1)); + AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, slider.Path.ControlPoints[index].Position, 1)); private class TestSliderBlueprint : SliderSelectionBlueprint { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs index 8235e1bc79..e724015905 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs @@ -385,10 +385,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void assertControlPointCount(int expected) => AddAssert($"has {expected} control points", () => getSlider().Path.ControlPoints.Count == expected); - private void assertControlPointType(int index, PathType type) => AddAssert($"control point {index} is {type}", () => getSlider().Path.ControlPoints[index].Type.Value == type); + private void assertControlPointType(int index, PathType type) => AddAssert($"control point {index} is {type}", () => getSlider().Path.ControlPoints[index].Type == type); private void assertControlPointPosition(int index, Vector2 position) => - AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, getSlider().Path.ControlPoints[index].Position.Value, 1)); + AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, getSlider().Path.ControlPoints[index].Position, 1)); private Slider getSlider() => HitObjectContainer.Count > 0 ? ((DrawableSlider)HitObjectContainer[0]).HitObject : null; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs index 0d828a79c8..cc43eb3852 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs @@ -184,7 +184,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { AddStep($"move mouse to control point {index}", () => { - Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position.Value; + Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position; InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); }); } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index 0ba775e5c7..37f1a846ad 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -45,8 +45,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { new Spinner { - Duration = 2000, - Position = OsuPlayfield.BASE_SIZE / 2 + Duration = 6000, + Position = OsuPlayfield.BASE_SIZE / 2, } } }, diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs index 1ac3ad9194..ed9da36b05 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs @@ -4,13 +4,11 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Screens.Play; using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods @@ -122,7 +120,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods private bool checkSomeHit() => Player.ScoreProcessor.JudgedHits >= 4; private bool objectWithIncreasedVisibilityHasIndex(int index) - => Player.Mods.Value.OfType().Single().FirstObject == Player.ChildrenOfType().Single().HitObjects[index]; + => Player.GameplayState.Mods.OfType().Single().FirstObject == Player.GameplayState.Beatmap.HitObjects[index]; private class TestOsuModHidden : OsuModHidden { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs new file mode 100644 index 0000000000..c14dc78f38 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.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.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModMuted : OsuModTestScene + { + /// + /// Ensures that a final volume combo of 0 (i.e. "always muted" mode) constantly plays metronome and completely mutes track. + /// + [Test] + public void TestZeroFinalCombo() => CreateModTest(new ModTestData + { + Mod = new OsuModMuted + { + MuteComboCount = { Value = 0 }, + }, + PassCondition = () => Beatmap.Value.Track.AggregateVolume.Value == 0.0 && + Player.ChildrenOfType().SingleOrDefault()?.AggregateVolume.Value == 1.0, + }); + + /// + /// Ensures that copying from a normal mod with 0 final combo while originally inversed does not yield incorrect results. + /// + [Test] + public void TestModCopy() + { + OsuModMuted muted = null; + + AddStep("create inversed mod", () => muted = new OsuModMuted + { + MuteComboCount = { Value = 100 }, + InverseMuting = { Value = true }, + }); + + AddStep("copy from normal", () => muted.CopyFrom(new OsuModMuted + { + MuteComboCount = { Value = 0 }, + InverseMuting = { Value = false }, + })); + + AddAssert("mute combo count = 0", () => muted.MuteComboCount.Value == 0); + AddAssert("inverse muting = false", () => muted.InverseMuting.Value == false); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index afd94f4570..15675e74d1 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,13 +15,13 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.9311451172574934d, "diffcalc-test")] - [TestCase(1.0736586907780401d, "zero-length-sliders")] + [TestCase(6.5867229481955389d, "diffcalc-test")] + [TestCase(1.0416315570967911d, "zero-length-sliders")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(8.7212283220412345d, "diffcalc-test")] - [TestCase(1.3212137158641493d, "zero-length-sliders")] + [TestCase(8.2730989071947896d, "diffcalc-test")] + [TestCase(1.2726413186221039d, "zero-length-sliders")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new OsuModDoubleTime()); diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png index a9b2d95d88..8e50cd0335 100755 Binary files a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/skin.ini b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/skin.ini new file mode 100644 index 0000000000..49ac2cf80d --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/skin.ini @@ -0,0 +1,3 @@ +[General] +Version: latest +HitCircleOverlayAboveNumber: 0 diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs index 10d9d7ffde..79150a1941 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs @@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Tests { Position = new Vector2(100, 300), }, - accuracyHeatmap = new TestAccuracyHeatmap(new ScoreInfo { Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }) + accuracyHeatmap = new TestAccuracyHeatmap(new ScoreInfo { BeatmapInfo = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs new file mode 100644 index 0000000000..bd39dead34 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs @@ -0,0 +1,175 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Legacy; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Skinning; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneCursorParticles : TestSceneOsuPlayer + { + protected override bool Autoplay => autoplay; + protected override bool HasCustomSteps => true; + + private bool autoplay; + private IBeatmap currentBeatmap; + + [Resolved] + private SkinManager skinManager { get; set; } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => currentBeatmap ?? base.CreateBeatmap(ruleset); + + [Test] + public void TestLegacyBreakParticles() + { + LegacyCursorParticles cursorParticles = null; + + createLegacyTest(false, () => new Beatmap + { + Breaks = + { + new BreakPeriod(8500, 10000), + }, + HitObjects = + { + new HitCircle + { + StartTime = 8000, + Position = OsuPlayfield.BASE_SIZE / 2, + }, + new HitCircle + { + StartTime = 11000, + Position = OsuPlayfield.BASE_SIZE / 2, + }, + } + }); + + AddUntilStep("fetch cursor particles", () => + { + cursorParticles = this.ChildrenOfType().SingleOrDefault(); + return cursorParticles != null; + }); + + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre)); + + AddAssert("particles are being spawned", () => cursorParticles.Active); + + AddStep("press left mouse button", () => InputManager.PressButton(MouseButton.Left)); + AddWaitStep("wait a bit", 5); + AddStep("press right mouse button", () => InputManager.PressButton(MouseButton.Right)); + AddWaitStep("wait a bit", 5); + AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + AddWaitStep("wait a bit", 5); + AddStep("release right mouse button", () => InputManager.ReleaseButton(MouseButton.Right)); + + AddUntilStep("wait for beatmap start", () => !Player.IsBreakTime.Value); + AddAssert("particle spawning stopped", () => !cursorParticles.Active); + + AddUntilStep("wait for break", () => Player.IsBreakTime.Value); + AddAssert("particles are being spawned", () => cursorParticles.Active); + + AddUntilStep("wait for break end", () => !Player.IsBreakTime.Value); + AddAssert("particle spawning stopped", () => !cursorParticles.Active); + } + + [Test] + public void TestLegacyKiaiParticles() + { + LegacyCursorParticles cursorParticles = null; + DrawableSpinner spinner = null; + DrawableSlider slider = null; + + createLegacyTest(true, () => + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + controlPointInfo.Add(5000, new EffectControlPoint { KiaiMode = false }); + + return new Beatmap + { + ControlPointInfo = controlPointInfo, + HitObjects = + { + new Spinner + { + StartTime = 0, + Duration = 1000, + Position = OsuPlayfield.BASE_SIZE / 2, + }, + new Slider + { + StartTime = 2500, + RepeatCount = 0, + Position = OsuPlayfield.BASE_SIZE / 2, + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero), + new PathControlPoint(new Vector2(100, 0)), + }) + }, + new HitCircle + { + StartTime = 4500, + Position = OsuPlayfield.BASE_SIZE / 2, + }, + }, + }; + } + ); + + AddUntilStep("fetch cursor particles", () => + { + cursorParticles = this.ChildrenOfType().SingleOrDefault(); + return cursorParticles != null; + }); + + AddUntilStep("wait for spinner tracking", () => + { + spinner = this.ChildrenOfType().SingleOrDefault(); + return spinner?.RotationTracker.Tracking == true; + }); + AddAssert("particles are being spawned", () => cursorParticles.Active); + + AddUntilStep("spinner tracking stopped", () => !spinner.RotationTracker.Tracking); + AddAssert("particle spawning stopped", () => !cursorParticles.Active); + + AddUntilStep("wait for slider tracking", () => + { + slider = this.ChildrenOfType().SingleOrDefault(); + return slider?.Tracking.Value == true; + }); + AddAssert("particles are being spawned", () => cursorParticles.Active); + + AddUntilStep("slider tracking stopped", () => !slider.Tracking.Value); + AddAssert("particle spawning stopped", () => !cursorParticles.Active); + } + + private void createLegacyTest(bool autoplay, Func beatmap) => CreateTest(() => + { + AddStep("set beatmap", () => + { + this.autoplay = autoplay; + currentBeatmap = beatmap(); + }); + AddStep("setup default legacy skin", () => + { + skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo; + }); + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index 78bb88322a..f09aad8b49 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -12,10 +12,12 @@ using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.Input; +using osu.Framework.Input.Events; using osu.Framework.Testing.Input; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Screens.Play; @@ -28,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Tests public class TestSceneGameplayCursor : OsuSkinnableTestScene { [Cached] - private GameplayBeatmap gameplayBeatmap; + private GameplayState gameplayState; private OsuCursorContainer lastContainer; @@ -39,7 +41,8 @@ namespace osu.Game.Rulesets.Osu.Tests public TestSceneGameplayCursor() { - gameplayBeatmap = new GameplayBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + var ruleset = new OsuRuleset(); + gameplayState = new GameplayState(CreateBeatmap(ruleset.RulesetInfo), ruleset, Array.Empty()); AddStep("change background colour", () => { @@ -56,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddSliderStep("circle size", 0f, 10f, 0f, val => { config.SetValue(OsuSetting.AutoCursorSize, true); - gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = val; + gameplayState.Beatmap.Difficulty.CircleSize = val; Scheduler.AddOnce(() => loadContent(false)); }); @@ -72,7 +75,7 @@ namespace osu.Game.Rulesets.Osu.Tests public void TestSizing(int circleSize, float userScale) { AddStep($"set user scale to {userScale}", () => config.SetValue(OsuSetting.GameplayCursorSize, userScale)); - AddStep($"adjust cs to {circleSize}", () => gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = circleSize); + AddStep($"adjust cs to {circleSize}", () => gameplayState.Beatmap.Difficulty.CircleSize = circleSize); AddStep("turn on autosizing", () => config.SetValue(OsuSetting.AutoCursorSize, true)); AddStep("load content", () => loadContent()); @@ -121,7 +124,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; } @@ -143,9 +146,9 @@ namespace osu.Game.Rulesets.Osu.Tests pressed = value; if (value) - OnPressed(OsuAction.LeftButton); + OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton)); else - OnReleased(OsuAction.LeftButton); + OnReleased(new KeyBindingReleaseEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton)); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs index 1fdcd73dde..575523b168 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs @@ -4,6 +4,7 @@ using System; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Osu.Objects; @@ -97,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void scheduleHit() => AddStep("schedule action", () => { var delay = hitCircle.StartTime - hitCircle.HitWindows.WindowFor(HitResult.Great) - Time.Current; - Scheduler.AddDelayed(() => hitAreaReceptor.OnPressed(OsuAction.LeftButton), delay); + Scheduler.AddDelayed(() => hitAreaReceptor.OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton)), delay); }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs index 56307861f1..8b51225e98 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs @@ -1,16 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Skinning; using osu.Game.Tests.Beatmaps; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests { @@ -77,23 +82,106 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("is custom user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestSkin.Colours)); } + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void TestComboOffsetWithBeatmapColours(bool userHasCustomColours, bool useBeatmapSkin) + { + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true, getHitCirclesWithLegacyOffsets())); + ConfigureTest(useBeatmapSkin, true, userHasCustomColours); + assertCorrectObjectComboColours("is beatmap skin colours with combo offsets applied", + TestBeatmapSkin.Colours, + (i, obj) => i + 1 + obj.ComboOffset); + } + + [TestCase(true)] + [TestCase(false)] + public void TestComboOffsetWithIgnoredBeatmapColours(bool useBeatmapSkin) + { + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true, getHitCirclesWithLegacyOffsets())); + ConfigureTest(useBeatmapSkin, false, true); + assertCorrectObjectComboColours("is user skin colours without combo offsets applied", + TestSkin.Colours, + (i, _) => i + 1); + } + + private void assertCorrectObjectComboColours(string description, Color4[] expectedColours, Func nextExpectedComboIndex) + { + AddUntilStep("wait for objects to become alive", () => + TestPlayer.DrawableRuleset.Playfield.AllHitObjects.Count() == TestPlayer.DrawableRuleset.Objects.Count()); + + AddAssert(description, () => + { + int index = 0; + + return TestPlayer.DrawableRuleset.Playfield.AllHitObjects.All(d => + { + index = nextExpectedComboIndex(index, (OsuHitObject)d.HitObject); + return checkComboColour(d, expectedColours[index % expectedColours.Length]); + }); + }); + + static bool checkComboColour(DrawableHitObject drawableHitObject, Color4 expectedColour) + { + return drawableHitObject.AccentColour.Value == expectedColour && + drawableHitObject.NestedHitObjects.All(n => checkComboColour(n, expectedColour)); + } + } + + private static IEnumerable getHitCirclesWithLegacyOffsets() + { + var hitObjects = new List(); + + for (int i = 0; i < 10; i++) + { + var hitObject = i % 2 == 0 + ? (OsuHitObject)new HitCircle() + : new Slider + { + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(100, 0)), + }) + }; + + hitObject.StartTime = i; + hitObject.Position = new Vector2(256, 192); + hitObject.NewCombo = true; + hitObject.ComboOffset = i; + + hitObjects.Add(hitObject); + } + + return hitObjects; + } + private class OsuCustomSkinWorkingBeatmap : CustomSkinWorkingBeatmap { - public OsuCustomSkinWorkingBeatmap(AudioManager audio, bool hasColours) - : base(createBeatmap(), audio, hasColours) + public OsuCustomSkinWorkingBeatmap(AudioManager audio, bool hasColours, IEnumerable hitObjects = null) + : base(createBeatmap(hitObjects), audio, hasColours) { } - private static IBeatmap createBeatmap() => - new Beatmap + private static IBeatmap createBeatmap(IEnumerable hitObjects) + { + var beatmap = new Beatmap { BeatmapInfo = { BeatmapSet = new BeatmapSetInfo(), Ruleset = new OsuRuleset().RulesetInfo, }, - HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } }; + + beatmap.HitObjects.AddRange(hitObjects ?? new[] + { + new HitCircle { Position = new Vector2(256, 192) } + }); + + return beatmap; + } } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs index af67ab5839..a5629119b6 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Tests }; var hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); CreateModTest(new ModTestData { @@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Tests }; var hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); CreateModTest(new ModTestData { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs index 77a68b714b..ececfb0586 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs @@ -400,9 +400,9 @@ namespace osu.Game.Rulesets.Osu.Tests Beatmap.Value = CreateWorkingBeatmap(new Beatmap { HitObjects = hitObjects, + Difficulty = new BeatmapDifficulty { SliderTickRate = 3 }, BeatmapInfo = { - BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 3 }, Ruleset = new OsuRuleset().RulesetInfo }, }); @@ -452,7 +452,7 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestSpinner : Spinner { - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); SpinsRequired = 1; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index 662cbaee68..0f362851a9 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -20,6 +20,7 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osu.Game.Storyboards; using osu.Game.Tests.Visual; @@ -86,9 +87,9 @@ namespace osu.Game.Rulesets.Osu.Tests if (firstObject == null) return false; - var skinnable = firstObject.ApproachCircle.Child as SkinnableDrawable; + var skinnable = firstObject.ApproachCircle; - if (skin == null && skinnable?.Drawable is Sprite) + if (skin == null && skinnable?.Drawable is DefaultApproachCircle) // check for default skin provider return true; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 9da583a073..52ab39cfbd 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -30,6 +30,9 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSpinnerRotation : TestSceneOsuPlayer { + private const double spinner_start_time = 100; + private const double spinner_duration = 6000; + [Resolved] private AudioManager audioManager { get; set; } @@ -77,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Tests double finalTrackerRotation = 0, trackerRotationTolerance = 0; double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; - addSeekStep(5000); + addSeekStep(spinner_start_time + 5000); AddStep("retrieve disc rotation", () => { finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; @@ -90,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests }); AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); - addSeekStep(2500); + addSeekStep(spinner_start_time + 2500); AddAssert("disc rotation rewound", // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. // due to the exponential damping applied we're allowing a larger margin of error of about 10% @@ -102,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Tests // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); - addSeekStep(5000); + addSeekStep(spinner_start_time + 5000); AddAssert("is disc rotation almost same", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance)); AddAssert("is symbol rotation almost same", @@ -140,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestSpinnerNormalBonusRewinding() { - addSeekStep(1000); + addSeekStep(spinner_start_time + 1000); AddAssert("player score matching expected bonus score", () => { @@ -201,24 +204,9 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0)); } - private Replay applyRateAdjustment(Replay scoreReplay, double rate) => new Replay - { - Frames = scoreReplay - .Frames - .Cast() - .Select(replayFrame => - { - var adjustedTime = replayFrame.Time * rate; - return new OsuReplayFrame(adjustedTime, replayFrame.Position, replayFrame.Actions.ToArray()); - }) - .Cast() - .ToList() - }; - private void addSeekStep(double time) { AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } @@ -241,7 +229,8 @@ namespace osu.Game.Rulesets.Osu.Tests new Spinner { Position = new Vector2(256, 192), - EndTime = 6000, + StartTime = spinner_start_time, + Duration = spinner_duration }, } }; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index 177a4f50a1..1b85e0efde 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -412,7 +412,7 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestSpinner : Spinner { - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); SpinsRequired = 1; 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 68be34d153..f5f1159542 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 141138c125..bd4c0f2ad5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -9,8 +9,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public double AimStrain { get; set; } public double SpeedStrain { get; set; } + public double FlashlightRating { get; set; } public double ApproachRate { get; set; } public double OverallDifficulty { get; set; } + public double DrainRate { 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 e47f82fb39..790aa0eb7d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -21,6 +21,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty public class OsuDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.0675; + private double hitWindowGreat; public OsuDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -34,14 +35,29 @@ namespace osu.Game.Rulesets.Osu.Difficulty double aimRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier; double speedRating = Math.Sqrt(skills[1].DifficultyValue()) * difficulty_multiplier; - double starRating = aimRating + speedRating + Math.Abs(aimRating - speedRating) / 2; + double flashlightRating = Math.Sqrt(skills[2].DifficultyValue()) * difficulty_multiplier; - HitWindows hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + if (mods.Any(h => h is OsuModRelax)) + speedRating = 0.0; - // Todo: These int casts are temporary to achieve 1:1 results with osu!stable, and should be removed in the future - double hitWindowGreat = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate; - double preempt = (int)BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / clockRate; + double baseAimPerformance = Math.Pow(5 * Math.Max(1, aimRating / 0.0675) - 4, 3) / 100000; + double baseSpeedPerformance = Math.Pow(5 * Math.Max(1, speedRating / 0.0675) - 4, 3) / 100000; + double baseFlashlightPerformance = 0.0; + + if (mods.Any(h => h is OsuModFlashlight)) + baseFlashlightPerformance = Math.Pow(flashlightRating, 2.0) * 25.0; + + double basePerformance = + Math.Pow( + Math.Pow(baseAimPerformance, 1.1) + + Math.Pow(baseSpeedPerformance, 1.1) + + Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 + ); + + double starRating = basePerformance > 0.00001 ? Math.Cbrt(1.12) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; + + double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; + double drainRate = beatmap.Difficulty.DrainRate; int maxCombo = beatmap.HitObjects.Count; // Add the ticks + tail of the slider. 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above) @@ -56,8 +72,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty Mods = mods, AimStrain = aimRating, SpeedStrain = speedRating, + FlashlightRating = flashlightRating, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, OverallDifficulty = (80 - hitWindowGreat) / 6, + DrainRate = drainRate, MaxCombo = maxCombo, HitCircleCount = hitCirclesCount, SpinnerCount = spinnerCount, @@ -79,11 +97,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty } } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - new Aim(mods), - new Speed(mods) - }; + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; + + return new Skill[] + { + new Aim(mods), + new Speed(mods, hitWindowGreat), + new Flashlight(mods) + }; + } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { @@ -91,6 +118,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty new OsuModHalfTime(), new OsuModEasy(), new OsuModHardRock(), + new OsuModFlashlight(), }; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 749d7d1b41..4e4dbc02a1 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,28 +35,36 @@ 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); + countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); + countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); + + double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. // 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 - if (mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * countMiss); if (mods.Any(m => m is OsuModSpunOut)) multiplier *= 1.0 - Math.Pow((double)Attributes.SpinnerCount / totalHits, 0.85); + if (mods.Any(h => h is OsuModRelax)) + { + countMiss += countOk + countMeh; + multiplier *= 0.6; + } + double aimValue = computeAimValue(); double speedValue = computeSpeedValue(); double accuracyValue = computeAccuracyValue(); + double flashlightValue = computeFlashlightValue(); double totalValue = Math.Pow( Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1) + - Math.Pow(accuracyValue, 1.1), 1.0 / 1.1 + Math.Pow(accuracyValue, 1.1) + + Math.Pow(flashlightValue, 1.1), 1.0 / 1.1 ) * multiplier; if (categoryRatings != null) @@ -65,6 +72,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty categoryRatings.Add("Aim", aimValue); categoryRatings.Add("Speed", speedValue); categoryRatings.Add("Accuracy", accuracyValue); + categoryRatings.Add("Flashlight", flashlightValue); categoryRatings.Add("OD", Attributes.OverallDifficulty); categoryRatings.Add("AR", Attributes.ApproachRate); categoryRatings.Add("Max Combo", Attributes.MaxCombo); @@ -82,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double aimValue = Math.Pow(5.0 * Math.Max(1.0, rawAim / 0.0675) - 4.0, 3.0) / 100000.0; - // Longer maps are worth more + // Longer maps are worth more. double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); @@ -92,35 +100,33 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (countMiss > 0) aimValue *= 0.97 * Math.Pow(1 - Math.Pow((double)countMiss / totalHits, 0.775), countMiss); - // Combo scaling + // Combo scaling. if (Attributes.MaxCombo > 0) aimValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); 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); - aimValue *= 1.0 + Math.Min(approachRateFactor, approachRateFactor * (totalHits / 1000.0)); + double approachRateTotalHitsFactor = 1.0 / (1.0 + Math.Exp(-(0.007 * (totalHits - 400)))); - // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - if (mods.Any(h => h is OsuModHidden)) - aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); + double approachRateBonus = 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; - if (mods.Any(h => h is OsuModFlashlight)) + if (mods.Any(m => m is OsuModBlinds)) + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * countMiss)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * Attributes.DrainRate * Attributes.DrainRate); + else if (mods.Any(h => h is OsuModHidden)) { - // Apply object-based bonus for flashlight. - aimValue *= 1.0 + 0.35 * Math.Min(1.0, totalHits / 200.0) + - (totalHits > 200 - ? 0.3 * Math.Min(1.0, (totalHits - 200) / 300.0) + - (totalHits > 500 ? (totalHits - 500) / 1200.0 : 0.0) - : 0.0); + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. + aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); } - // Scale the aim value with accuracy _slightly_ + aimValue *= approachRateBonus; + + // Scale the aim value with accuracy _slightly_. aimValue *= 0.5 + accuracy / 2.0; - // It is important to also consider accuracy difficulty when doing that + // It is important to also consider accuracy difficulty when doing that. aimValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500; return aimValue; @@ -130,7 +136,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty { double speedValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.SpeedStrain / 0.0675) - 4.0, 3.0) / 100000.0; - // Longer maps are worth more + // Longer maps are worth more. double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); speedValue *= lengthBonus; @@ -139,21 +145,32 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (countMiss > 0) speedValue *= 0.97 * Math.Pow(1 - Math.Pow((double)countMiss / totalHits, 0.775), Math.Pow(countMiss, .875)); - // Combo scaling + // Combo scaling. if (Attributes.MaxCombo > 0) speedValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); 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)))); - if (mods.Any(m => m is OsuModHidden)) + speedValue *= 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; + + if (mods.Any(m => m is OsuModBlinds)) + { + // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. + speedValue *= 1.12; + } + else if (mods.Any(m => m is OsuModHidden)) + { + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. speedValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); + } - // Scale the speed value with accuracy and OD + // Scale the speed value with accuracy and OD. speedValue *= (0.95 + Math.Pow(Attributes.OverallDifficulty, 2) / 750) * Math.Pow(accuracy, (14.5 - Math.Max(Attributes.OverallDifficulty, 8)) / 2); + // Scale the speed value with # of 50s to punish doubletapping. speedValue *= Math.Pow(0.98, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); @@ -162,7 +179,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double computeAccuracyValue() { - // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window + if (mods.Any(h => h is OsuModRelax)) + return 0.0; + + // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. double betterAccuracyPercentage; int amountHitObjectsWithAccuracy = Attributes.HitCircleCount; @@ -171,25 +191,65 @@ namespace osu.Game.Rulesets.Osu.Difficulty else betterAccuracyPercentage = 0; - // It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points + // It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points. if (betterAccuracyPercentage < 0) betterAccuracyPercentage = 0; // Lots of arbitrary values from testing. - // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution + // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. double accuracyValue = Math.Pow(1.52163, Attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; - // Bonus for many hitcircles - it's harder to keep good accuracy up for longer + // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); - if (mods.Any(m => m is OsuModHidden)) + // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. + if (mods.Any(m => m is OsuModBlinds)) + accuracyValue *= 1.14; + else if (mods.Any(m => m is OsuModHidden)) accuracyValue *= 1.08; + if (mods.Any(m => m is OsuModFlashlight)) accuracyValue *= 1.02; return accuracyValue; } + private double computeFlashlightValue() + { + if (!mods.Any(h => h is OsuModFlashlight)) + return 0.0; + + double rawFlashlight = Attributes.FlashlightRating; + + if (mods.Any(m => m is OsuModTouchDevice)) + rawFlashlight = Math.Pow(rawFlashlight, 0.8); + + double flashlightValue = Math.Pow(rawFlashlight, 2.0) * 25.0; + + // Add an additional bonus for HDFL. + if (mods.Any(h => h is OsuModHidden)) + flashlightValue *= 1.3; + + // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. + if (countMiss > 0) + flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow((double)countMiss / totalHits, 0.775), Math.Pow(countMiss, .875)); + + // Combo scaling. + if (Attributes.MaxCombo > 0) + flashlightValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); + + // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. + flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) + + (totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0); + + // Scale the flashlight value with accuracy _slightly_. + flashlightValue *= 0.5 + accuracy / 2.0; + // It is important to also consider accuracy difficulty when doing that. + flashlightValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500; + + return flashlightValue; + } + private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalSuccessfulHits => countGreat + countOk + countMeh; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index fa6c5c4d9c..8e8f9bc06e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -16,6 +16,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; + /// + /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms to account for simultaneous s. + /// + public double StrainTime { get; private set; } + /// /// Normalized distance from the end position of the previous to the start position of this . /// @@ -32,11 +37,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double? Angle { get; private set; } - /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 50ms. - /// - public readonly double StrainTime; - private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; @@ -48,8 +48,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing setDistances(); - // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure - StrainTime = Math.Max(50, DeltaTime); + // Capped to 25ms to prevent difficulty calculation breaking from simulatenous objects. + StrainTime = Math.Max(DeltaTime, 25); } private void setDistances() diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index cb819ec090..d8f4aa1229 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -3,7 +3,6 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances. /// - public class Aim : StrainSkill + public class Aim : OsuStrainSkill { private const double angle_bonus_begin = Math.PI / 3; private const double timing_threshold = 107; @@ -23,17 +22,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills { } - protected override double SkillMultiplier => 26.25; - protected override double StrainDecayBase => 0.15; + private double currentStrain = 1; - protected override double StrainValueOf(DifficultyHitObject current) + private double skillMultiplier => 26.25; + private double strainDecayBase => 0.15; + + private double strainValueOf(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; var osuCurrent = (OsuDifficultyHitObject)current; - double result = 0; + double aimStrain = 0; if (Previous.Count > 0) { @@ -47,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills Math.Max(osuPrevious.JumpDistance - scale, 0) * Math.Pow(Math.Sin(osuCurrent.Angle.Value - angle_bonus_begin), 2) * Math.Max(osuCurrent.JumpDistance - scale, 0)); - result = 1.5 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); + aimStrain = 1.4 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); } } @@ -55,11 +56,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills double travelDistanceExp = applyDiminishingExp(osuCurrent.TravelDistance); return Math.Max( - result + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(osuCurrent.StrainTime, timing_threshold), + aimStrain + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(osuCurrent.StrainTime, timing_threshold), (Math.Sqrt(travelDistanceExp * jumpDistanceExp) + jumpDistanceExp + travelDistanceExp) / osuCurrent.StrainTime ); } private double applyDiminishingExp(double val) => Math.Pow(val, 0.99); + + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double CalculateInitialStrain(double time) => currentStrain * strainDecay(time - Previous[0].StartTime); + + protected override double StrainValueAt(DifficultyHitObject current) + { + currentStrain *= strainDecay(current.DeltaTime); + currentStrain += strainValueOf(current) * skillMultiplier; + + return currentStrain; + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs new file mode 100644 index 0000000000..e3abe7d700 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Skills +{ + /// + /// Represents the skill required to memorise and hit every object in a map with the Flashlight mod enabled. + /// + public class Flashlight : OsuStrainSkill + { + public Flashlight(Mod[] mods) + : base(mods) + { + } + + private double skillMultiplier => 0.15; + private double strainDecayBase => 0.15; + protected override double DecayWeight => 1.0; + protected override int HistoryLength => 10; // Look back for 10 notes is added for the sake of flashlight calculations. + private double currentStrain = 1; + + private double strainValueOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + var osuCurrent = (OsuDifficultyHitObject)current; + var osuHitObject = (OsuHitObject)(osuCurrent.BaseObject); + + double scalingFactor = 52.0 / osuHitObject.Radius; + double smallDistNerf = 1.0; + double cumulativeStrainTime = 0.0; + + double result = 0.0; + + for (int i = 0; i < Previous.Count; i++) + { + var osuPrevious = (OsuDifficultyHitObject)Previous[i]; + var osuPreviousHitObject = (OsuHitObject)(osuPrevious.BaseObject); + + if (!(osuPrevious.BaseObject is Spinner)) + { + double jumpDistance = (osuHitObject.StackedPosition - osuPreviousHitObject.EndPosition).Length; + + cumulativeStrainTime += osuPrevious.StrainTime; + + // We want to nerf objects that can be easily seen within the Flashlight circle radius. + if (i == 0) + smallDistNerf = Math.Min(1.0, jumpDistance / 75.0); + + // We also want to nerf stacks so that only the first object of the stack is accounted for. + double stackNerf = Math.Min(1.0, (osuPrevious.JumpDistance / scalingFactor) / 25.0); + + result += Math.Pow(0.8, i) * stackNerf * scalingFactor * jumpDistance / cumulativeStrainTime; + } + } + + return Math.Pow(smallDistNerf * result, 2.0); + } + + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double CalculateInitialStrain(double time) => currentStrain * strainDecay(time - Previous[0].StartTime); + + protected override double StrainValueAt(DifficultyHitObject current) + { + currentStrain *= strainDecay(current.DeltaTime); + currentStrain += strainValueOf(current) * skillMultiplier; + + return currentStrain; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs new file mode 100644 index 0000000000..e47edc37cc --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; +using System.Linq; +using osu.Framework.Utils; + +namespace osu.Game.Rulesets.Osu.Difficulty.Skills +{ + public abstract class OsuStrainSkill : StrainSkill + { + /// + /// The number of sections with the highest strains, which the peak strain reductions will apply to. + /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. + /// + protected virtual int ReducedSectionCount => 10; + + /// + /// The baseline multiplier applied to the section with the biggest strain. + /// + protected virtual double ReducedStrainBaseline => 0.75; + + /// + /// The final multiplier to be applied to after all other calculations. + /// + protected virtual double DifficultyMultiplier => 1.06; + + protected OsuStrainSkill(Mod[] mods) + : base(mods) + { + } + + public override double DifficultyValue() + { + double difficulty = 0; + double weight = 1; + + List strains = GetCurrentStrainPeaks().OrderByDescending(d => d).ToList(); + + // We are reducing the highest strains first to account for extreme difficulty spikes + for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / ReducedSectionCount, 0, 1))); + strains[i] *= Interpolation.Lerp(ReducedStrainBaseline, 1.0, scale); + } + + // Difficulty is the weighted sum of the highest strains from every section. + // We're sorting from highest to lowest strain. + foreach (double strain in strains.OrderByDescending(d => d)) + { + difficulty += strain * weight; + weight *= DecayWeight; + } + + return difficulty * DifficultyMultiplier; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index fbac080fc6..cae6b8e01c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -3,67 +3,174 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; +using osu.Framework.Utils; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { /// /// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit. /// - public class Speed : StrainSkill + public class Speed : OsuStrainSkill { private const double single_spacing_threshold = 125; - - private const double angle_bonus_begin = 5 * Math.PI / 6; - private const double pi_over_4 = Math.PI / 4; - private const double pi_over_2 = Math.PI / 2; - - protected override double SkillMultiplier => 1400; - protected override double StrainDecayBase => 0.3; - + private const double rhythm_multiplier = 0.75; + private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max. private const double min_speed_bonus = 75; // ~200BPM - private const double max_speed_bonus = 45; // ~330BPM private const double speed_balancing_factor = 40; - public Speed(Mod[] mods) + private double skillMultiplier => 1375; + private double strainDecayBase => 0.3; + + private double currentStrain = 1; + private double currentRhythm = 1; + + protected override int ReducedSectionCount => 5; + protected override double DifficultyMultiplier => 1.04; + protected override int HistoryLength => 32; + + private readonly double greatWindow; + + public Speed(Mod[] mods, double hitWindowGreat) : base(mods) { + greatWindow = hitWindowGreat; } - protected override double StrainValueOf(DifficultyHitObject current) + /// + /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . + /// + private double calculateRhythmBonus(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; - var osuCurrent = (OsuDifficultyHitObject)current; + int previousIslandSize = 0; - double distance = Math.Min(single_spacing_threshold, osuCurrent.TravelDistance + osuCurrent.JumpDistance); - double deltaTime = Math.Max(max_speed_bonus, current.DeltaTime); + double rhythmComplexitySum = 0; + int islandSize = 1; + double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms - double speedBonus = 1.0; - if (deltaTime < min_speed_bonus) - speedBonus = 1 + Math.Pow((min_speed_bonus - deltaTime) / speed_balancing_factor, 2); + bool firstDeltaSwitch = false; - double angleBonus = 1.0; - - if (osuCurrent.Angle != null && osuCurrent.Angle.Value < angle_bonus_begin) + for (int i = Previous.Count - 2; i > 0; i--) { - angleBonus = 1 + Math.Pow(Math.Sin(1.5 * (angle_bonus_begin - osuCurrent.Angle.Value)), 2) / 3.57; + OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)Previous[i - 1]; + OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)Previous[i]; + OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)Previous[i + 1]; - if (osuCurrent.Angle.Value < pi_over_2) + double currHistoricalDecay = Math.Max(0, (history_time_max - (current.StartTime - currObj.StartTime))) / history_time_max; // scales note 0 to 1 from history to now + + if (currHistoricalDecay != 0) { - angleBonus = 1.28; - if (distance < 90 && osuCurrent.Angle.Value < pi_over_4) - angleBonus += (1 - angleBonus) * Math.Min((90 - distance) / 10, 1); - else if (distance < 90) - angleBonus += (1 - angleBonus) * Math.Min((90 - distance) / 10, 1) * Math.Sin((pi_over_2 - osuCurrent.Angle.Value) / pi_over_4); + currHistoricalDecay = Math.Min((double)(Previous.Count - i) / Previous.Count, currHistoricalDecay); // either we're limited by time or limited by object count. + + double currDelta = currObj.StrainTime; + double prevDelta = prevObj.StrainTime; + double lastDelta = lastObj.StrainTime; + double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. + + double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - greatWindow * 0.6) / (greatWindow * 0.6)); + + windowPenalty = Math.Min(1, windowPenalty); + + double effectiveRatio = windowPenalty * currRatio; + + if (firstDeltaSwitch) + { + if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) + { + if (islandSize < 7) + islandSize++; // island is still progressing, count size. + } + else + { + if (Previous[i - 1].BaseObject is Slider) // bpm change is into slider, this is easy acc window + effectiveRatio *= 0.125; + + if (Previous[i].BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle + effectiveRatio *= 0.25; + + if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) + effectiveRatio *= 0.25; + + if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) + effectiveRatio *= 0.50; + + if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + effectiveRatio *= 0.125; + + rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; + + startRatio = effectiveRatio; + + previousIslandSize = islandSize; // log the last island size. + + if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting + firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + + islandSize = 1; + } + } + else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + { + // Begin counting island until we change speed again. + firstDeltaSwitch = true; + startRatio = effectiveRatio; + islandSize = 1; + } } } - return (1 + (speedBonus - 1) * 0.75) * angleBonus * (0.95 + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / osuCurrent.StrainTime; + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though) + } + + private double strainValueOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + // derive strainTime for calculation + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuPrevObj = Previous.Count > 0 ? (OsuDifficultyHitObject)Previous[0] : null; + + double strainTime = osuCurrObj.StrainTime; + double greatWindowFull = greatWindow * 2; + double speedWindowRatio = strainTime / greatWindowFull; + + // Aim to nerf cheesy rhythms (Very fast consecutive doubles with large deltatimes between) + if (osuPrevObj != null && strainTime < greatWindowFull && osuPrevObj.StrainTime > strainTime) + strainTime = Interpolation.Lerp(osuPrevObj.StrainTime, strainTime, speedWindowRatio); + + // Cap deltatime to the OD 300 hitwindow. + // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. + strainTime /= Math.Clamp((strainTime / greatWindowFull) / 0.93, 0.92, 1); + + // derive speedBonus for calculation + double speedBonus = 1.0; + + if (strainTime < min_speed_bonus) + speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); + + double distance = Math.Min(single_spacing_threshold, osuCurrObj.TravelDistance + osuCurrObj.JumpDistance); + + return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / strainTime; + } + + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double CalculateInitialStrain(double time) => (currentStrain * currentRhythm) * strainDecay(time - Previous[0].StartTime); + + protected override double StrainValueAt(DifficultyHitObject current) + { + currentStrain *= strainDecay(current.DeltaTime); + currentStrain += strainValueOf(current) * skillMultiplier; + + currentRhythm = calculateRhythmBonus(current); + + return currentStrain * currentRhythm; } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs index eb7011e8b0..d66c9ea4bf 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// private void updateConnectingPath() { - Position = slider.StackedPosition + ControlPoint.Position.Value; + Position = slider.StackedPosition + ControlPoint.Position; path.ClearVertices(); @@ -69,7 +69,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return; path.AddVertex(Vector2.Zero); - path.AddVertex(slider.Path.ControlPoints[nextIndex].Position.Value - ControlPoint.Position.Value); + path.AddVertex(slider.Path.ControlPoints[nextIndex].Position - ControlPoint.Position); path.OriginPosition = path.PositionInBoundingBox(Vector2.Zero); } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs index 5b476526c9..2cc95e1891 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -53,7 +53,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private IBindable sliderPosition; private IBindable sliderScale; - private IBindable controlPointPosition; public PathControlPointPiece(Slider slider, PathControlPoint controlPoint) { @@ -69,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components updatePathType(); }); - controlPoint.Type.BindValueChanged(_ => updateMarkerDisplay()); + controlPoint.Changed += updateMarkerDisplay; Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; @@ -117,9 +116,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components sliderPosition = slider.PositionBindable.GetBoundCopy(); sliderPosition.BindValueChanged(_ => updateMarkerDisplay()); - controlPointPosition = ControlPoint.Position.GetBoundCopy(); - controlPointPosition.BindValueChanged(_ => updateMarkerDisplay()); - sliderScale = slider.ScaleBindable.GetBoundCopy(); sliderScale.BindValueChanged(_ => updateMarkerDisplay()); @@ -174,8 +170,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (e.Button == MouseButton.Left) { - dragStartPosition = ControlPoint.Position.Value; - dragPathType = PointsInSegment[0].Type.Value; + dragStartPosition = ControlPoint.Position; + dragPathType = PointsInSegment[0].Type; changeHandler?.BeginChange(); return true; @@ -186,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components protected override void OnDrag(DragEvent e) { - Vector2[] oldControlPoints = slider.Path.ControlPoints.Select(cp => cp.Position.Value).ToArray(); + Vector2[] oldControlPoints = slider.Path.ControlPoints.Select(cp => cp.Position).ToArray(); var oldPosition = slider.Position; var oldStartTime = slider.StartTime; @@ -202,15 +198,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components // Since control points are relative to the position of the slider, they all need to be offset backwards by the delta for (int i = 1; i < slider.Path.ControlPoints.Count; i++) - slider.Path.ControlPoints[i].Position.Value -= movementDelta; + slider.Path.ControlPoints[i].Position -= movementDelta; } else - ControlPoint.Position.Value = dragStartPosition + (e.MousePosition - e.MouseDownPosition); + ControlPoint.Position = dragStartPosition + (e.MousePosition - e.MouseDownPosition); if (!slider.Path.HasValidLength) { for (var i = 0; i < slider.Path.ControlPoints.Count; i++) - slider.Path.ControlPoints[i].Position.Value = oldControlPoints[i]; + slider.Path.ControlPoints[i].Position = oldControlPoints[i]; slider.Position = oldPosition; slider.StartTime = oldStartTime; @@ -218,7 +214,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } // Maintain the path type in case it got defaulted to bezier at some point during the drag. - PointsInSegment[0].Type.Value = dragPathType; + PointsInSegment[0].Type = dragPathType; } protected override void OnDragEnd(DragEndEvent e) => changeHandler?.EndChange(); @@ -230,19 +226,19 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// private void updatePathType() { - if (ControlPoint.Type.Value != PathType.PerfectCurve) + if (ControlPoint.Type != PathType.PerfectCurve) return; if (PointsInSegment.Count > 3) - ControlPoint.Type.Value = PathType.Bezier; + ControlPoint.Type = PathType.Bezier; if (PointsInSegment.Count != 3) return; - ReadOnlySpan points = PointsInSegment.Select(p => p.Position.Value).ToArray(); + ReadOnlySpan points = PointsInSegment.Select(p => p.Position).ToArray(); RectangleF boundingBox = PathApproximator.CircularArcBoundingBox(points); if (boundingBox.Width >= 640 || boundingBox.Height >= 480) - ControlPoint.Type.Value = PathType.Bezier; + ControlPoint.Type = PathType.Bezier; } /// @@ -250,7 +246,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// private void updateMarkerDisplay() { - Position = slider.StackedPosition + ControlPoint.Position.Value; + Position = slider.StackedPosition + ControlPoint.Position; markerRing.Alpha = IsSelected.Value ? 1 : 0; @@ -265,7 +261,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private Color4 getColourFromNodeType() { - if (!(ControlPoint.Type.Value is PathType pathType)) + if (!(ControlPoint.Type is PathType pathType)) return colours.Yellow; switch (pathType) @@ -284,6 +280,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } } - public LocalisableString TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty; + public LocalisableString TooltipText => ControlPoint.Type.ToString() ?? string.Empty; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index c36768baba..1be9b5bf2e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -127,18 +127,18 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return false; } - public bool OnPressed(PlatformAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action.ActionMethod) + switch (e.Action) { - case PlatformActionMethod.Delete: + case PlatformAction.Delete: return DeleteSelected(); } return false; } - public void OnReleased(PlatformAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -173,12 +173,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components int thirdPointIndex = indexInSegment + 2; if (piece.PointsInSegment.Count > thirdPointIndex + 1) - piece.PointsInSegment[thirdPointIndex].Type.Value = piece.PointsInSegment[0].Type.Value; + piece.PointsInSegment[thirdPointIndex].Type = piece.PointsInSegment[0].Type; break; } - piece.ControlPoint.Type.Value = type; + piece.ControlPoint.Type = type; } [Resolved(CanBeNull = true)] @@ -241,7 +241,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private MenuItem createMenuItemForPathType(PathType? type) { int totalCount = Pieces.Count(p => p.IsSelected.Value); - int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type.Value == type); + int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type == type); var item = new TernaryStateRadioMenuItem(type == null ? "Inherit" : type.ToString().Humanize(), MenuItemType.Standard, _ => { diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 8b20df9a68..b9e4ed6fcb 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -108,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders Debug.Assert(lastPoint != null); segmentStart = lastPoint; - segmentStart.Type.Value = PathType.Linear; + segmentStart.Type = PathType.Linear; currentSegmentLength = 1; } @@ -153,15 +153,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { case 1: case 2: - segmentStart.Type.Value = PathType.Linear; + segmentStart.Type = PathType.Linear; break; case 3: - segmentStart.Type.Value = PathType.PerfectCurve; + segmentStart.Type = PathType.PerfectCurve; break; default: - segmentStart.Type.Value = PathType.Bezier; + segmentStart.Type = PathType.Bezier; break; } } @@ -173,7 +173,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders // The cursor does not overlap a previous control point, so it can be added if not already existing. if (cursor == null) { - HitObject.Path.ControlPoints.Add(cursor = new PathControlPoint { Position = { Value = Vector2.Zero } }); + HitObject.Path.ControlPoints.Add(cursor = new PathControlPoint { Position = Vector2.Zero }); // The path type should be adjusted in the progression of updatePathType() (Linear -> PC -> Bezier). currentSegmentLength++; @@ -181,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Update the cursor position. - cursor.Position.Value = ToLocalSpace(inputManager.CurrentState.Mouse.Position) - HitObject.Position; + cursor.Position = ToLocalSpace(inputManager.CurrentState.Mouse.Position) - HitObject.Position; } else if (cursor != null) { diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index e810d2fe0c..89724876fa 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -161,7 +161,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { Debug.Assert(placementControlPointIndex != null); - HitObject.Path.ControlPoints[placementControlPointIndex.Value].Position.Value = e.MousePosition - HitObject.Position; + HitObject.Path.ControlPoints[placementControlPointIndex.Value].Position = e.MousePosition - HitObject.Position; } protected override void OnDragEnd(DragEndEvent e) @@ -182,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders for (int i = 0; i < controlPoints.Count - 1; i++) { - float dist = new Line(controlPoints[i].Position.Value, controlPoints[i + 1].Position.Value).DistanceToPoint(position); + float dist = new Line(controlPoints[i].Position, controlPoints[i + 1].Position).DistanceToPoint(position); if (dist < minDistance) { @@ -192,7 +192,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Move the control points from the insertion index onwards to make room for the insertion - controlPoints.Insert(insertionIndex, new PathControlPoint { Position = { Value = position } }); + controlPoints.Insert(insertionIndex, new PathControlPoint { Position = position }); return insertionIndex; } @@ -207,8 +207,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { // The first control point in the slider must have a type, so take it from the previous "first" one // Todo: Should be handled within SliderPath itself - if (c == controlPoints[0] && controlPoints.Count > 1 && controlPoints[1].Type.Value == null) - controlPoints[1].Type.Value = controlPoints[0].Type.Value; + if (c == controlPoints[0] && controlPoints.Count > 1 && controlPoints[1].Type == null) + controlPoints[1].Type = controlPoints[0].Type; controlPoints.Remove(c); } @@ -222,9 +222,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders // The path will have a non-zero offset if the head is removed, but sliders don't support this behaviour since the head is positioned at the slider's position // So the slider needs to be offset by this amount instead, and all control points offset backwards such that the path is re-positioned at (0, 0) - Vector2 first = controlPoints[0].Position.Value; + Vector2 first = controlPoints[0].Position; foreach (var c in controlPoints) - c.Position.Value -= first; + c.Position -= first; HitObject.Position += first; } 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..f0aade1b7f --- /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.Difficulty.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 0e61c02e2d..c89527d8bd 100644 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs @@ -41,6 +41,11 @@ namespace osu.Game.Rulesets.Osu.Edit protected override GameplayCursorContainer CreateCursor() => null; + public OsuEditorPlayfield() + { + HitPolicy = new AnyOrderHitPolicy(); + } + [BackgroundDependencyLoader] private void load(OsuConfigManager config) { @@ -59,11 +64,14 @@ namespace osu.Game.Rulesets.Osu.Edit if (hitObject is DrawableHitCircle circle) { - circle.ApproachCircle - .FadeOutFromOne(EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION * 4) - .Expire(); + using (circle.BeginAbsoluteSequence(circle.HitStateUpdateTime)) + { + circle.ApproachCircle + .FadeOutFromOne(EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION * 4) + .Expire(); - circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint); + circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint); + } } if (hitObject is IHasMainCirclePiece mainPieceContainer) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs index 896e904f3f..221723e4cd 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs @@ -15,10 +15,12 @@ namespace osu.Game.Rulesets.Osu.Edit { // Compose new CheckOffscreenObjects(), + new CheckTooShortSpinners(), // Spread new CheckTimeDistanceEquality(), - new CheckLowDiffOverlaps() + new CheckLowDiffOverlaps(), + new CheckTooShortSliders(), }; public IEnumerable Run(BeatmapVerifierContext context) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 806b7e6051..1e84ec80e1 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -42,10 +42,12 @@ namespace osu.Game.Rulesets.Osu.Edit }; private readonly Bindable distanceSnapToggle = new Bindable(); + private readonly Bindable rectangularGridSnapToggle = new Bindable(); protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] { - new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }), + new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) }); private BindableList selectedHitObjects; @@ -63,6 +65,10 @@ namespace osu.Game.Rulesets.Osu.Edit PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }, distanceSnapGridContainer = new Container + { + RelativeSizeAxes = Axes.Both + }, + rectangularPositionSnapGrid = new OsuRectangularPositionSnapGrid { RelativeSizeAxes = Axes.Both } @@ -73,7 +79,19 @@ namespace osu.Game.Rulesets.Osu.Edit placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); placementObject.ValueChanged += _ => updateDistanceSnapGrid(); - distanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); + distanceSnapToggle.ValueChanged += _ => + { + updateDistanceSnapGrid(); + + if (distanceSnapToggle.Value == TernaryState.True) + rectangularGridSnapToggle.Value = TernaryState.False; + }; + + rectangularGridSnapToggle.ValueChanged += _ => + { + if (rectangularGridSnapToggle.Value == TernaryState.True) + distanceSnapToggle.Value = TernaryState.False; + }; // we may be entering the screen with a selection already active updateDistanceSnapGrid(); @@ -91,6 +109,8 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly Cached distanceSnapGridCache = new Cached(); private double? lastDistanceSnapGridTime; + private RectangularPositionSnapGrid rectangularPositionSnapGrid; + protected override void Update() { base.Update(); @@ -122,13 +142,19 @@ namespace osu.Game.Rulesets.Osu.Edit if (positionSnap.ScreenSpacePosition != screenSpacePosition) return positionSnap; - // will be null if distance snap is disabled or not feasible for the current time value. - if (distanceSnapGrid == null) - return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + if (distanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + { + (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } - (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + if (rectangularGridSnapToggle.Value == TernaryState.True) + { + Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(screenSpacePosition)); + return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } - return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); } private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..b8ff92bd37 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class OsuRectangularPositionSnapGrid : RectangularPositionSnapGrid, IKeyBindingHandler + { + private static readonly int[] grid_sizes = { 4, 8, 16, 32 }; + + private int currentGridSizeIndex = grid_sizes.Length - 1; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + + public OsuRectangularPositionSnapGrid() + : base(OsuPlayfield.BASE_SIZE / 2) + { + } + + [BackgroundDependencyLoader] + private void load() + { + var gridSizeIndex = Array.IndexOf(grid_sizes, editorBeatmap.BeatmapInfo.GridSize); + if (gridSizeIndex >= 0) + currentGridSizeIndex = gridSizeIndex; + updateSpacing(); + } + + private void nextGridSize() + { + currentGridSizeIndex = (currentGridSizeIndex + 1) % grid_sizes.Length; + updateSpacing(); + } + + private void updateSpacing() + { + int gridSize = grid_sizes[currentGridSizeIndex]; + + editorBeatmap.BeatmapInfo.GridSize = gridSize; + Spacing = new Vector2(gridSize); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.EditorCycleGridDisplayMode: + nextGridSize(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 57d0cd859d..4a57d36eb4 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -35,8 +35,8 @@ namespace osu.Game.Rulesets.Osu.Edit Quad quad = selectedMovableObjects.Length > 0 ? getSurroundingQuad(selectedMovableObjects) : new Quad(); SelectionBox.CanRotate = quad.Width > 0 || quad.Height > 0; - SelectionBox.CanScaleX = quad.Width > 0; - SelectionBox.CanScaleY = quad.Height > 0; + SelectionBox.CanFlipX = SelectionBox.CanScaleX = quad.Width > 0; + SelectionBox.CanFlipY = SelectionBox.CanScaleY = quad.Height > 0; SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider); } @@ -76,32 +76,8 @@ namespace osu.Game.Rulesets.Osu.Edit if (h is Slider slider) { - var points = slider.Path.ControlPoints.ToArray(); - Vector2 endPos = points.Last().Position.Value; - - slider.Path.ControlPoints.Clear(); - - slider.Position += endPos; - - PathType? lastType = null; - - for (var i = 0; i < points.Length; i++) - { - var p = points[i]; - p.Position.Value -= endPos; - - // propagate types forwards to last null type - if (i == points.Length - 1) - p.Type.Value = lastType; - else if (p.Type.Value != null) - { - var newType = p.Type.Value; - p.Type.Value = lastType; - lastType = newType; - } - - slider.Path.ControlPoints.Insert(0, p); - } + slider.Path.Reverse(out Vector2 offset); + slider.Position += offset; } } @@ -122,9 +98,9 @@ namespace osu.Game.Rulesets.Osu.Edit { foreach (var point in slider.Path.ControlPoints) { - point.Position.Value = new Vector2( - (direction == Direction.Horizontal ? -1 : 1) * point.Position.Value.X, - (direction == Direction.Vertical ? -1 : 1) * point.Position.Value.Y + point.Position = new Vector2( + (direction == Direction.Horizontal ? -1 : 1) * point.Position.X, + (direction == Direction.Vertical ? -1 : 1) * point.Position.Y ); } } @@ -177,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Edit if (h is IHasPath path) { foreach (var point in path.Path.ControlPoints) - point.Position.Value = RotatePointAroundOrigin(point.Position.Value, Vector2.Zero, delta); + point.Position = RotatePointAroundOrigin(point.Position, Vector2.Zero, delta); } } @@ -187,9 +163,9 @@ namespace osu.Game.Rulesets.Osu.Edit private void scaleSlider(Slider slider, Vector2 scale) { - referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type.Value).ToList(); + referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type).ToList(); - Quad sliderQuad = GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position.Value)); + Quad sliderQuad = GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position)); // Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0. scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size; @@ -202,13 +178,13 @@ namespace osu.Game.Rulesets.Osu.Edit foreach (var point in slider.Path.ControlPoints) { - oldControlPoints.Enqueue(point.Position.Value); - point.Position.Value *= pathRelativeDeltaScale; + oldControlPoints.Enqueue(point.Position); + point.Position *= pathRelativeDeltaScale; } // Maintain the path types in case they were defaulted to bezier at some point during scaling for (int i = 0; i < slider.Path.ControlPoints.Count; ++i) - slider.Path.ControlPoints[i].Type.Value = referencePathTypes[i]; + slider.Path.ControlPoints[i].Type = referencePathTypes[i]; //if sliderhead or sliderend end up outside playfield, revert scaling. Quad scaledQuad = getSurroundingQuad(new OsuHitObject[] { slider }); @@ -218,7 +194,7 @@ namespace osu.Game.Rulesets.Osu.Edit return; foreach (var point in slider.Path.ControlPoints) - point.Position.Value = oldControlPoints.Dequeue(); + point.Position = oldControlPoints.Dequeue(); } private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale) diff --git a/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs b/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs new file mode 100644 index 0000000000..8cb778a2e1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Screens.Edit.Setup; + +namespace osu.Game.Rulesets.Osu.Edit.Setup +{ + public class OsuSetupSection : RulesetSetupSection + { + private LabelledSliderBar stackLeniency; + + public OsuSetupSection() + : base(new OsuRuleset().RulesetInfo) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new[] + { + stackLeniency = new LabelledSliderBar + { + Label = "Stack Leniency", + Description = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.", + Current = new BindableFloat(Beatmap.BeatmapInfo.StackLeniency) + { + Default = 0.7f, + MinValue = 0, + MaxValue = 1, + Precision = 0.1f + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + stackLeniency.Current.BindValueChanged(_ => updateBeatmap()); + } + + private void updateBeatmap() + { + Beatmap.BeatmapInfo.StackLeniency = stackLeniency.Current.Value; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index ebf6f9dda7..3102db270e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -32,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableRuleset.Overlays.Add(blinds = new DrawableOsuBlinds(drawableRuleset.Playfield.HitObjectContainer, drawableRuleset.Beatmap)); + drawableRuleset.Overlays.Add(blinds = new DrawableOsuBlinds(drawableRuleset.Playfield, drawableRuleset.Beatmap)); } public void ApplyToHealthProcessor(HealthProcessor healthProcessor) @@ -128,8 +129,21 @@ namespace osu.Game.Rulesets.Osu.Mods protected override void Update() { - float start = Parent.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopLeft).X; - float end = Parent.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopRight).X; + float start, end; + + if (Precision.AlmostEquals(restrictTo.Rotation, 0)) + { + start = Parent.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopLeft).X; + end = Parent.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopRight).X; + } + else + { + float center = restrictTo.ToSpaceOfOtherDrawable(restrictTo.OriginPosition, Parent).X; + float halfDiagonal = (restrictTo.DrawSize / 2).LengthFast; + + start = center - halfDiagonal; + end = center + halfDiagonal; + } float rawWidth = end - start; @@ -158,17 +172,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/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/OsuModHardRock.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs index 16c166257a..007820b016 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.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 System; using System.Linq; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.UI; -using osuTK; +using osu.Game.Rulesets.Osu.Utils; namespace osu.Game.Rulesets.Osu.Mods { @@ -15,23 +14,13 @@ namespace osu.Game.Rulesets.Osu.Mods { public override double ScoreMultiplier => 1.06; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModMirror)).ToArray(); + public void ApplyToHitObject(HitObject hitObject) { var osuObject = (OsuHitObject)hitObject; - osuObject.Position = new Vector2(osuObject.Position.X, OsuPlayfield.BASE_SIZE.Y - osuObject.Y); - - if (!(hitObject is Slider slider)) - return; - - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - - var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray(); - foreach (var point in controlPoints) - point.Position.Value = new Vector2(point.Position.Value.X, -point.Position.Value.Y); - - slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + OsuHitObjectGenerationUtils.ReflectVertically(osuObject); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs new file mode 100644 index 0000000000..3faca0b01f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Utils; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModMirror : ModMirror, IApplicableToHitObject + { + public override string Description => "Flip objects on the chosen axes."; + public override Type[] IncompatibleMods => new[] { typeof(ModHardRock) }; + + [SettingSource("Mirrored axes", "Choose which axes objects are mirrored over.")] + public Bindable Reflection { get; } = new Bindable(); + + public void ApplyToHitObject(HitObject hitObject) + { + var osuObject = (OsuHitObject)hitObject; + + switch (Reflection.Value) + { + case MirrorType.Horizontal: + OsuHitObjectGenerationUtils.ReflectHorizontally(osuObject); + break; + + case MirrorType.Vertical: + OsuHitObjectGenerationUtils.ReflectVertically(osuObject); + break; + + case MirrorType.Both: + OsuHitObjectGenerationUtils.ReflectHorizontally(osuObject); + OsuHitObjectGenerationUtils.ReflectVertically(osuObject); + break; + } + } + + public enum MirrorType + { + Horizontal, + Vertical, + Both + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMuted.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMuted.cs new file mode 100644 index 0000000000..5e3ee37b61 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMuted.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModMuted : ModMuted + { + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index d1212096bf..1a2e5d92b4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -26,6 +26,11 @@ namespace osu.Game.Rulesets.Osu.Mods 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) @@ -49,8 +54,9 @@ namespace osu.Game.Rulesets.Osu.Mods var current = new RandomObjectInfo(hitObject); - // rateOfChangeMultiplier only changes every i iterations to prevent shaky-line-shaped streams - if (i % 3 == 0) + // 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) @@ -61,13 +67,35 @@ namespace osu.Game.Rulesets.Osu.Mods applyRandomisation(rateOfChangeMultiplier, previous, current); - hitObject.Position = current.PositionRandomised; + // Move hit objects back into the playfield if they are outside of it + Vector2 shift = Vector2.Zero; - // update end position as it may have changed as a result of the position update. - current.EndPositionRandomised = current.PositionRandomised; + switch (hitObject) + { + case HitCircle circle: + shift = clampHitCircleToPlayfield(circle, current); + break; - if (hitObject is Slider slider) - moveSliderIntoPlayfield(slider, current); + 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; } @@ -94,7 +122,9 @@ namespace osu.Game.Rulesets.Osu.Mods // 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. - var randomAngleRad = rateOfChangeMultiplier * 2 * Math.PI * distanceToPrev / playfield_diagonal; + + // 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) @@ -109,56 +139,120 @@ namespace osu.Game.Rulesets.Osu.Mods current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X); - var position = previous.EndPositionRandomised + posRelativeToPrev; + current.PositionRandomised = previous.EndPositionRandomised + posRelativeToPrev; + } - // Move hit objects back into the playfield if they are outside of it, - // which would sometimes happen during big jumps otherwise. - position.X = MathHelper.Clamp(position.X, 0, OsuPlayfield.BASE_SIZE.X); - position.Y = MathHelper.Clamp(position.Y, 0, OsuPlayfield.BASE_SIZE.Y); + /// + /// 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 + ); - current.PositionRandomised = position; + circle.Position = objectInfo.PositionRandomised; + + return objectInfo.PositionRandomised - previousPosition; } /// /// Moves the and all necessary nested s into the if they aren't already. /// - private void moveSliderIntoPlayfield(Slider slider, RandomObjectInfo currentObjectInfo) + /// The deviation from the original randomised position in order to fit within the playfield. + private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo) { - var minMargin = getMinSliderMargin(slider); + var possibleMovementBounds = calculatePossibleMovementBounds(slider); - slider.Position = new Vector2( - Math.Clamp(slider.Position.X, minMargin.Left, OsuPlayfield.BASE_SIZE.X - minMargin.Right), - Math.Clamp(slider.Position.Y, minMargin.Top, OsuPlayfield.BASE_SIZE.Y - minMargin.Bottom) - ); + var previousPosition = objectInfo.PositionRandomised; - currentObjectInfo.PositionRandomised = slider.Position; - currentObjectInfo.EndPositionRandomised = slider.EndPosition; + // 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); - shiftNestedObjects(slider, currentObjectInfo.PositionRandomised - currentObjectInfo.PositionOriginal); + 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; } /// - /// Calculates the min. distances from the 's position to the playfield border for the slider to be fully inside of the playfield. + /// 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. /// - private MarginPadding getMinSliderMargin(Slider slider) + /// 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); - var minMargin = new MarginPadding(); + 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) { - minMargin.Left = Math.Max(minMargin.Left, -pos.X); - minMargin.Right = Math.Max(minMargin.Right, pos.X); - minMargin.Top = Math.Max(minMargin.Top, -pos.Y); - minMargin.Bottom = Math.Max(minMargin.Bottom, pos.Y); + minX = MathF.Min(minX, pos.X); + maxX = MathF.Max(maxX, pos.X); + + minY = MathF.Min(minY, pos.Y); + maxY = MathF.Max(maxY, pos.Y); } - minMargin.Left = Math.Min(minMargin.Left, OsuPlayfield.BASE_SIZE.X - minMargin.Right); - minMargin.Top = Math.Min(minMargin.Top, OsuPlayfield.BASE_SIZE.Y - minMargin.Bottom); + // Take the circle radius into account. + var radius = (float)slider.Radius; - return minMargin; + 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); } /// @@ -177,6 +271,20 @@ namespace osu.Game.Rulesets.Osu.Mods } } + /// + /// 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; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs index 56c246953e..95e7d13ee7 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs @@ -44,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/OsuModTarget.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs index 2464308347..bf70a63ab5 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs @@ -1,13 +1,39 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.Linq; +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.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 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 +41,466 @@ 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 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(IBeatmapDifficultyInfo 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.Difficulty); + 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 Metronome(drawableRuleset.Beatmap.HitObjects.First().StartTime)); + } + + #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/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index a05e4dea03..07ce009cf8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -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/FollowPointLifetimeEntry.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs index 82bca0a4e2..30ff6b8984 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs @@ -4,6 +4,7 @@ #nullable enable using System; +using System.Diagnostics; using osu.Framework.Bindables; using osu.Framework.Graphics.Performance; using osu.Game.Rulesets.Objects; @@ -20,8 +21,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { Start = start; LifetimeStart = Start.StartTime; - - bindEvents(); } private OsuHitObject? end; @@ -41,31 +40,39 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections } } + private bool wasBound; + private void bindEvents() { UnbindEvents(); + if (End == null) + return; + // Note: Positions are bound for instantaneous feedback from positional changes from the editor, before ApplyDefaults() is called on hitobjects. Start.DefaultsApplied += onDefaultsApplied; Start.PositionBindable.ValueChanged += onPositionChanged; - if (End != null) - { - End.DefaultsApplied += onDefaultsApplied; - End.PositionBindable.ValueChanged += onPositionChanged; - } + End.DefaultsApplied += onDefaultsApplied; + End.PositionBindable.ValueChanged += onPositionChanged; + + wasBound = true; } public void UnbindEvents() { + if (!wasBound) + return; + + Debug.Assert(End != null); + Start.DefaultsApplied -= onDefaultsApplied; Start.PositionBindable.ValueChanged -= onPositionChanged; - if (End != null) - { - End.DefaultsApplied -= onDefaultsApplied; - End.PositionBindable.ValueChanged -= onPositionChanged; - } + End.DefaultsApplied -= onDefaultsApplied; + End.PositionBindable.ValueChanged -= onPositionChanged; + + wasBound = false; } private void onDefaultsApplied(HitObject obj) => refreshLifetimes(); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 46fc8f99b2..f05aea0df4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Judgements; @@ -25,7 +26,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public OsuAction? HitAction => HitArea.HitAction; protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; - public ApproachCircle ApproachCircle { get; private set; } + public SkinnableDrawable ApproachCircle { get; private set; } public HitReceptor HitArea { get; private set; } public SkinnableDrawable CirclePiece { get; private set; } @@ -74,8 +75,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - ApproachCircle = new ApproachCircle + ApproachCircle = new ProxyableSkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.ApproachCircle), _ => new DefaultApproachCircle()) { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, Alpha = 0, Scale = new Vector2(4), } @@ -88,7 +92,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); - AccentColour.BindValueChanged(accent => ApproachCircle.Colour = accent.NewValue); } protected override void LoadComplete() @@ -228,15 +231,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables CornerExponent = 2; } - public bool OnPressed(OsuAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case OsuAction.LeftButton: case OsuAction.RightButton: if (IsHovered && (Hit?.Invoke() ?? false)) { - HitAction = action; + HitAction = e.Action; return true; } @@ -246,7 +249,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return false; } - public void OnReleased(OsuAction action) + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + + private class ProxyableSkinnableDrawable : SkinnableDrawable + { + public override bool RemoveWhenNotAlive => false; + + public ProxyableSkinnableDrawable(ISkinComponent component, Func defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(component, defaultImplementation, confineMode) { } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 79655c33e4..e4df41a4fe 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -74,10 +74,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public override void PlayAnimation() { - base.PlayAnimation(); - if (Result != HitResult.Miss) - JudgementText.TransformSpacingTo(Vector2.Zero).Then().TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); + { + JudgementText + .ScaleTo(new Vector2(0.8f, 1)) + .ScaleTo(new Vector2(1.2f, 1), 1800, Easing.OutQuint); + } + + base.PlayAnimation(); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 0bec33bf77..3acec4498d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -32,6 +32,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public SliderBall Ball { get; private set; } public SkinnableDrawable Body { get; private set; } + /// + /// A target container which can be used to add top level elements to the slider's display. + /// Intended to be used for proxy purposes only. + /// + public Container OverlayElementContainer { get; private set; } + public override bool DisplayResult => !HitObject.OnlyJudgeNestedObjects; [CanBeNull] @@ -65,6 +71,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables tailContainer = new Container { RelativeSizeAxes = Axes.Both }, tickContainer = new Container { RelativeSizeAxes = Axes.Both }, repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, + headContainer = new Container { RelativeSizeAxes = Axes.Both }, + OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, }, Ball = new SliderBall(this) { GetInitialHitAction = () => HeadCircle.HitAction, @@ -72,7 +80,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables AlwaysPresent = true, Alpha = 0 }, - headContainer = new Container { RelativeSizeAxes = Axes.Both }, slidingSample = new PausableSkinnableSound { Looping = true } }; @@ -179,6 +186,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables tailContainer.Clear(false); repeatContainer.Clear(false); tickContainer.Clear(false); + + OverlayElementContainer.Clear(false); } protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index 01c0d988ee..2b026e6840 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [CanBeNull] public Slider Slider => DrawableSlider?.HitObject; - protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; + public DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; public override bool DisplayResult => HitObject?.JudgeAsNormalHitCircle ?? base.DisplayResult; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 4a2a18ffd6..673211ac6c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [CanBeNull] public Slider Slider => DrawableSlider?.HitObject; - protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; + public DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; private double animDuration; diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 22b64af3df..7c45b2bc07 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -97,6 +97,14 @@ namespace osu.Game.Rulesets.Osu.Objects set => ComboIndexBindable.Value = value; } + public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + + public int ComboIndexWithOffsets + { + get => ComboIndexWithOffsetsBindable.Value; + set => ComboIndexWithOffsetsBindable.Value = value; + } + public Bindable LastInComboBindable { get; } = new Bindable(); public bool LastInCombo @@ -114,11 +122,11 @@ namespace osu.Game.Rulesets.Osu.Objects }); } - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, PREEMPT_MIN); + TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, PREEMPT_MIN); // Preempt time can go below 450ms. Normally, this is achieved via the DT mod which uniformly speeds up all animations game wide regardless of AR. // This uniform speedup is hard to match 1:1, however we can at least make AR>10 (via mods) feel good by extending the upper linear function above. diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 8ba9597dc3..1d2666f46b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Osu.Objects if (value != null) { - path.ControlPoints.AddRange(value.ControlPoints.Select(c => new PathControlPoint(c.Position.Value, c.Type.Value))); + path.ControlPoints.AddRange(value.ControlPoints.Select(c => new PathControlPoint(c.Position, c.Type))); path.ExpectedDistance.Value = value.ExpectedDistance.Value; } } @@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Objects Path.Version.ValueChanged += _ => updateNestedPositions(); } - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs index a6aed2c00e..f893559548 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Objects public double SpanDuration => slider.SpanDuration; - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index 725dbe81fb..e7e64954e9 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Objects public int SpanIndex { get; set; } public double SpanStartTime { get; set; } - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index 194aa640f9..f85dc0d391 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Objects /// public int MaximumBonusSpins { get; protected set; } = 1; - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); @@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Objects double secondsDuration = Duration / 1000; - double minimumRotationsPerSecond = stable_matching_fudge * BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 3, 5, 7.5); + double minimumRotationsPerSecond = stable_matching_fudge * IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, 3, 5, 7.5); SpinsRequired = (int)(secondsDuration * minimumRotationsPerSecond); MaximumBonusSpins = (int)((maximum_rotations_per_second - minimumRotationsPerSecond) * secondsDuration); diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 5f37b0d040..f4a93a571d 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -30,9 +30,11 @@ using osu.Game.Skinning; using System; using System.Linq; using osu.Framework.Extensions.EnumExtensions; +using osu.Game.Rulesets.Osu.Edit.Setup; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets.Osu @@ -166,6 +168,7 @@ namespace osu.Game.Rulesets.Osu new OsuModDifficultyAdjust(), new OsuModClassic(), new OsuModRandom(), + new OsuModMirror(), }; case ModType.Automation: @@ -188,6 +191,7 @@ namespace osu.Game.Rulesets.Osu new OsuModTraceable(), new OsuModBarrelRoll(), new OsuModApproachDifferent(), + new OsuModMuted(), }; case ModType.System: @@ -303,5 +307,7 @@ namespace osu.Game.Rulesets.Osu } }; } + + public override RulesetSetupSection CreateEditorSetupSection() => new OsuSetupSection(); } } diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index 46e501758b..71657ed532 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -9,6 +9,7 @@ namespace osu.Game.Rulesets.Osu FollowPoint, Cursor, CursorTrail, + CursorParticles, SliderScorePoint, ReverseArrow, HitCircleText, @@ -18,5 +19,6 @@ namespace osu.Game.Rulesets.Osu SliderBall, SliderBody, SpinnerBody, + ApproachCircle, } } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 7b0cf651c8..e231550e3e 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Replays : base(beatmap, mods) { defaultHitWindows = new OsuHitWindows(); - defaultHitWindows.SetDifficulty(Beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + defaultHitWindows.SetDifficulty(Beatmap.Difficulty.OverallDifficulty); } #endregion @@ -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/ApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Default/ApproachCircle.cs deleted file mode 100644 index 62f00a2b49..0000000000 --- a/osu.Game.Rulesets.Osu/Skinning/Default/ApproachCircle.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Textures; -using osu.Game.Skinning; -using osuTK; - -namespace osu.Game.Rulesets.Osu.Skinning.Default -{ - public class ApproachCircle : Container - { - public override bool RemoveWhenNotAlive => false; - - public ApproachCircle() - { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - RelativeSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - Child = new SkinnableApproachCircle(); - } - - private class SkinnableApproachCircle : SkinnableSprite - { - public SkinnableApproachCircle() - : base("Gameplay/osu/approachcircle") - { - } - - protected override Drawable CreateDefault(ISkinComponent component) - { - var drawable = base.CreateDefault(component); - - // account for the sprite being used for the default approach circle being taken from stable, - // when hitcircles have 5px padding on each size. this should be removed if we update the sprite. - drawable.Scale = new Vector2(128 / 118f); - - return drawable; - } - } - } -} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultApproachCircle.cs new file mode 100644 index 0000000000..a522367fe6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultApproachCircle.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.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public class DefaultApproachCircle : SkinnableSprite + { + private readonly IBindable accentColour = new Bindable(); + + [Resolved] + private DrawableHitObject drawableObject { get; set; } + + public DefaultApproachCircle() + : base("Gameplay/osu/approachcircle") + { + } + + [BackgroundDependencyLoader] + private void load() + { + accentColour.BindTo(drawableObject.AccentColour); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + accentColour.BindValueChanged(colour => Colour = colour.NewValue, true); + } + + protected override Drawable CreateDefault(ISkinComponent component) + { + var drawable = base.CreateDefault(component); + + // Although this is a non-legacy component, osu-resources currently stores approach circle as a legacy-like texture. + // See LegacyApproachCircle for documentation as to why this is required. + drawable.Scale = new Vector2(128 / 118f); + + return drawable; + } + } +} 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/PlaySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs index 4dd7b2d69c..8602ebc88b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs @@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default pathVersion.BindValueChanged(_ => Refresh()); accentColour = drawableObject.AccentColour.GetBoundCopy(); - accentColour.BindValueChanged(accent => updateAccentColour(skin, accent.NewValue), true); + accentColour.BindValueChanged(accent => AccentColour = GetBodyAccentColour(skin, accent.NewValue), true); config?.BindWith(OsuRulesetSetting.SnakingInSliders, SnakingIn); config?.BindWith(OsuRulesetSetting.SnakingOutSliders, configSnakingOut); @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default } } - private void updateAccentColour(ISkinSource skin, Color4 defaultAccentColour) - => AccentColour = skin.GetConfig(OsuSkinColour.SliderTrackOverride)?.Value ?? defaultAccentColour; + protected virtual Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) => + skin.GetConfig(OsuSkinColour.SliderTrackOverride)?.Value ?? hitObjectAccentColour; } } 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/Default/SnakingSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SnakingSliderBody.cs index ed4e04184b..7b7a89d5e2 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SnakingSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SnakingSliderBody.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// /// A which changes its curve depending on the snaking progress. /// - public class SnakingSliderBody : SliderBody, ISliderProgress + public abstract class SnakingSliderBody : SliderBody, ISliderProgress { public readonly List CurrentCurve = new List(); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs new file mode 100644 index 0000000000..6a2cb871b1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.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.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public class LegacyApproachCircle : SkinnableSprite + { + private readonly IBindable accentColour = new Bindable(); + + [Resolved] + private DrawableHitObject drawableObject { get; set; } + + public LegacyApproachCircle() + : base("Gameplay/osu/approachcircle") + { + } + + [BackgroundDependencyLoader] + private void load() + { + accentColour.BindTo(drawableObject.AccentColour); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + accentColour.BindValueChanged(colour => Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue), true); + } + + protected override Drawable CreateDefault(ISkinComponent component) + { + var drawable = base.CreateDefault(component); + + // account for the sprite being used for the default approach circle being taken from stable, + // when hitcircles have 5px padding on each size. this should be removed if we update the sprite. + drawable.Scale = new Vector2(128 / 118f); + + return drawable; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs new file mode 100644 index 0000000000..611ddd08eb --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs @@ -0,0 +1,253 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Play; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public class LegacyCursorParticles : CompositeDrawable, IKeyBindingHandler + { + public bool Active => breakSpewer?.Active.Value == true || kiaiSpewer?.Active.Value == true; + + private LegacyCursorParticleSpewer breakSpewer; + private LegacyCursorParticleSpewer kiaiSpewer; + + [Resolved(canBeNull: true)] + private Player player { get; set; } + + [Resolved(canBeNull: true)] + private OsuPlayfield playfield { get; set; } + + [Resolved(canBeNull: true)] + private GameplayState gameplayState { get; set; } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, OsuColour colours) + { + var texture = skin.GetTexture("star2"); + var starBreakAdditive = skin.GetConfig(OsuSkinColour.StarBreakAdditive)?.Value ?? new Color4(255, 182, 193, 255); + + if (texture != null) + { + // stable "magic ratio". see OsuPlayfieldAdjustmentContainer for full explanation. + texture.ScaleAdjust *= 1.6f; + } + + InternalChildren = new[] + { + breakSpewer = new LegacyCursorParticleSpewer(texture, 20) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = starBreakAdditive, + Direction = SpewDirection.None, + }, + kiaiSpewer = new LegacyCursorParticleSpewer(texture, 60) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = starBreakAdditive, + Direction = SpewDirection.None, + }, + }; + + if (player != null) + ((IBindable)breakSpewer.Active).BindTo(player.IsBreakTime); + } + + protected override void Update() + { + if (playfield == null || gameplayState == null) return; + + DrawableHitObject kiaiHitObject = null; + + // Check whether currently in a kiai section first. This is only done as an optimisation to avoid enumerating AliveObjects when not necessary. + if (gameplayState.Beatmap.ControlPointInfo.EffectPointAt(Time.Current).KiaiMode) + kiaiHitObject = playfield.HitObjectContainer.AliveObjects.FirstOrDefault(isTracking); + + kiaiSpewer.Active.Value = kiaiHitObject != null; + } + + private bool isTracking(DrawableHitObject h) + { + if (!h.HitObject.Kiai) + return false; + + switch (h) + { + case DrawableSlider slider: + return slider.Tracking.Value; + + case DrawableSpinner spinner: + return spinner.RotationTracker.Tracking; + } + + return false; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + handleInput(e.Action, true); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + handleInput(e.Action, false); + } + + private bool leftPressed; + private bool rightPressed; + + private void handleInput(OsuAction action, bool pressed) + { + switch (action) + { + case OsuAction.LeftButton: + leftPressed = pressed; + break; + + case OsuAction.RightButton: + rightPressed = pressed; + break; + } + + if (leftPressed && rightPressed) + breakSpewer.Direction = SpewDirection.Omni; + else if (leftPressed) + breakSpewer.Direction = SpewDirection.Left; + else if (rightPressed) + breakSpewer.Direction = SpewDirection.Right; + else + breakSpewer.Direction = SpewDirection.None; + } + + private class LegacyCursorParticleSpewer : ParticleSpewer, IRequireHighFrequencyMousePosition + { + private const int particle_duration_min = 300; + private const int particle_duration_max = 1000; + + public SpewDirection Direction { get; set; } + + protected override bool CanSpawnParticles => base.CanSpawnParticles && cursorScreenPosition.HasValue; + protected override float ParticleGravity => 240; + + public LegacyCursorParticleSpewer(Texture texture, int perSecond) + : base(texture, perSecond, particle_duration_max) + { + Active.BindValueChanged(_ => resetVelocityCalculation()); + } + + private Vector2? cursorScreenPosition; + private Vector2 cursorVelocity; + + private const double max_velocity_frame_length = 15; + private double velocityFrameLength; + private Vector2 totalPosDifference; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (cursorScreenPosition == null) + { + cursorScreenPosition = e.ScreenSpaceMousePosition; + return base.OnMouseMove(e); + } + + // calculate cursor velocity. + totalPosDifference += e.ScreenSpaceMousePosition - cursorScreenPosition.Value; + cursorScreenPosition = e.ScreenSpaceMousePosition; + + velocityFrameLength += Math.Abs(Clock.ElapsedFrameTime); + + if (velocityFrameLength > max_velocity_frame_length) + { + cursorVelocity = totalPosDifference / (float)velocityFrameLength; + + totalPosDifference = Vector2.Zero; + velocityFrameLength = 0; + } + + return base.OnMouseMove(e); + } + + private void resetVelocityCalculation() + { + cursorScreenPosition = null; + totalPosDifference = Vector2.Zero; + velocityFrameLength = 0; + } + + protected override FallingParticle CreateParticle() => + new FallingParticle + { + StartPosition = ToLocalSpace(cursorScreenPosition ?? Vector2.Zero), + Duration = RNG.NextSingle(particle_duration_min, particle_duration_max), + StartAngle = (float)(RNG.NextDouble() * 4 - 2), + EndAngle = RNG.NextSingle(-2f, 2f), + EndScale = RNG.NextSingle(2f), + Velocity = getVelocity(), + }; + + private Vector2 getVelocity() + { + Vector2 velocity = Vector2.Zero; + + switch (Direction) + { + case SpewDirection.Left: + velocity = new Vector2( + RNG.NextSingle(-460f, 0), + RNG.NextSingle(-40f, 40f) + ); + break; + + case SpewDirection.Right: + velocity = new Vector2( + RNG.NextSingle(0, 460f), + RNG.NextSingle(-40f, 40f) + ); + break; + + case SpewDirection.Omni: + velocity = new Vector2( + RNG.NextSingle(-460f, 460f), + RNG.NextSingle(-160f, 160f) + ); + break; + } + + velocity += cursorVelocity * 40; + + return velocity; + } + } + + private enum SpewDirection + { + None, + Left, + Right, + Omni, + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs index f6fd3e36ab..75d847d54d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs @@ -9,6 +9,7 @@ using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { @@ -21,6 +22,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private double lastTrailTime; private IBindable cursorSize; + private Vector2? currentPosition; + public LegacyCursorTrail(ISkin skin) { this.skin = skin; @@ -54,22 +57,35 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy } protected override double FadeDuration => disjointTrail ? 150 : 500; + protected override float FadeExponent => 1; protected override bool InterpolateMovements => !disjointTrail; protected override float IntervalMultiplier => 1 / Math.Max(cursorSize.Value, 1); + protected override bool AvoidDrawingNearCursor => !disjointTrail; + + protected override void Update() + { + base.Update(); + + if (!disjointTrail || !currentPosition.HasValue) + return; + + if (Time.Current - lastTrailTime >= disjoint_trail_time_separation) + { + lastTrailTime = Time.Current; + AddTrail(currentPosition.Value); + } + } protected override bool OnMouseMove(MouseMoveEvent e) { if (!disjointTrail) return base.OnMouseMove(e); - if (Time.Current - lastTrailTime >= disjoint_trail_time_separation) - { - lastTrailTime = Time.Current; - return base.OnMouseMove(e); - } + currentPosition = e.ScreenSpaceMousePosition; + // Intentionally block the base call as we're adding the trails ourselves. return false; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index 7a210324d7..d1c9b1bf92 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -33,10 +33,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); } - private Container circleSprites; private Drawable hitCircleSprite; - private Drawable hitCircleOverlay; + protected Container OverlayLayer { get; private set; } + + private Drawable hitCircleOverlay; private SkinnableSpriteText hitCircleText; private readonly Bindable accentColour = new Bindable(); @@ -70,34 +71,30 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png (potentially from the default/fall-through skin). Texture overlayTexture = getTextureWithFallback("overlay"); - InternalChildren = new Drawable[] + InternalChildren = new[] { - circleSprites = new Container + hitCircleSprite = new KiaiFlashingSprite + { + Texture = baseTexture, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + OverlayLayer = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Children = new[] + Child = hitCircleOverlay = new KiaiFlashingSprite { - hitCircleSprite = new KiaiFlashingSprite - { - Texture = baseTexture, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - hitCircleOverlay = new KiaiFlashingSprite - { - Texture = overlayTexture, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - } - }, + Texture = overlayTexture, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } }; if (hasNumber) { - AddInternal(hitCircleText = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText + OverlayLayer.Add(hitCircleText = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText { Font = OsuFont.Numeric.With(size: 40), UseFullGlyphHeight = false, @@ -111,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy bool overlayAboveNumber = skin.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumber)?.Value ?? true; if (overlayAboveNumber) - AddInternal(hitCircleOverlay.CreateProxy()); + OverlayLayer.ChangeChildDepth(hitCircleOverlay, float.MinValue); accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); @@ -153,8 +150,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy switch (state) { case ArmedState.Hit: - circleSprites.FadeOut(legacy_fade_duration, Easing.Out); - circleSprites.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); + hitCircleSprite.FadeOut(legacy_fade_duration, Easing.Out); + hitCircleSprite.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); + + hitCircleOverlay.FadeOut(legacy_fade_duration, Easing.Out); + hitCircleOverlay.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); if (hasNumber) { diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index ae8d6a61f8..1e170036e4 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -100,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 cbe721d21d..e3e8f3ce88 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs @@ -89,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/LegacyReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs new file mode 100644 index 0000000000..7a071b5a03 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.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.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public class LegacyReverseArrow : CompositeDrawable + { + [Resolved(canBeNull: true)] + private DrawableHitObject drawableHitObject { get; set; } + + private Drawable proxy; + + [BackgroundDependencyLoader] + private void load(ISkinSource skinSource) + { + AutoSizeAxes = Axes.Both; + + string lookupName = new OsuSkinComponent(OsuSkinComponents.ReverseArrow).LookupName; + + var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null); + InternalChild = skin?.GetAnimation(lookupName, true, true) ?? Empty(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + proxy = CreateProxy(); + + if (drawableHitObject != null) + { + drawableHitObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(drawableHitObject); + } + } + + private void onHitObjectApplied(DrawableHitObject drawableObject) + { + Debug.Assert(proxy.Parent == null); + + // see logic in LegacySliderHeadHitCircle. + (drawableObject as DrawableSliderRepeat)?.DrawableSlider + .OverlayElementContainer.Add(proxy); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + if (drawableHitObject != null) + drawableHitObject.HitObjectApplied -= onHitObjectApplied; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs index 744ded37c9..29a0745193 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs @@ -3,9 +3,10 @@ using System; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Utils; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Skinning; +using osu.Game.Utils; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy @@ -14,6 +15,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { protected override DrawableSliderPath CreateSliderPath() => new LegacyDrawableSliderPath(); + protected override Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) + { + // legacy skins use a constant value for slider track alpha, regardless of the source colour. + return base.GetBodyAccentColour(skin, hitObjectAccentColour).Opacity(0.7f); + } + private class LegacyDrawableSliderPath : DrawableSliderPath { private const float shadow_portion = 1 - (OsuLegacySkinTransformer.LEGACY_CIRCLE_RADIUS / OsuHitObject.OBJECT_RADIUS); @@ -22,8 +29,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // Roughly matches osu!stable's slider border portions. => base.CalculatedBorderPortion * 0.77f; - public new Color4 AccentColour => new Color4(base.AccentColour.R, base.AccentColour.G, base.AccentColour.B, base.AccentColour.A * 0.70f); - protected override Color4 ColourAt(float position) { float realBorderPortion = shadow_portion + CalculatedBorderPortion; @@ -40,7 +45,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Color4 outerColour = AccentColour.Darken(0.1f); Color4 innerColour = lighten(AccentColour, 0.5f); - return Interpolation.ValueAt(position / realGradientPortion, outerColour, innerColour, 0, 1); + return LegacyUtils.InterpolateNonLinear(position / realGradientPortion, outerColour, innerColour, 0, 1); } /// diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderHeadHitCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderHeadHitCircle.cs new file mode 100644 index 0000000000..7de2b8c7fa --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderHeadHitCircle.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public class LegacySliderHeadHitCircle : LegacyMainCirclePiece + { + [Resolved(canBeNull: true)] + private DrawableHitObject drawableHitObject { get; set; } + + private Drawable proxiedOverlayLayer; + + public LegacySliderHeadHitCircle() + : base("sliderstartcircle") + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + proxiedOverlayLayer = OverlayLayer.CreateProxy(); + + if (drawableHitObject != null) + { + drawableHitObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(drawableHitObject); + } + } + + private void onHitObjectApplied(DrawableHitObject drawableObject) + { + Debug.Assert(proxiedOverlayLayer.Parent == null); + + // see logic in LegacyReverseArrow. + (drawableObject as DrawableSliderHead)?.DrawableSlider + .OverlayElementContainer.Add(proxiedOverlayLayer.With(d => d.Depth = float.MinValue)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableHitObject != null) + drawableHitObject.HitObjectApplied -= onHitObjectApplied; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index b2311dcb91..93aba608e6 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs @@ -140,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); @@ -150,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 @@ -182,14 +182,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy 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 41b0a88f11..ff9f6f0e07 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -67,7 +67,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy case OsuSkinComponents.SliderHeadHitCircle: if (hasHitCircle.Value) - return new LegacyMainCirclePiece("sliderstartcircle"); + return new LegacySliderHeadHitCircle(); + + return null; + + case OsuSkinComponents.ReverseArrow: + if (hasHitCircle.Value) + return new LegacyReverseArrow(); return null; @@ -89,6 +95,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return null; + case OsuSkinComponents.CursorParticles: + if (GetTexture("star2") != null) + return new LegacyCursorParticles(); + + return null; + case OsuSkinComponents.HitCircleText: if (!this.HasFont(LegacyFont.HitCircle)) return null; @@ -108,6 +120,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return new LegacyOldStyleSpinner(); return null; + + case OsuSkinComponents.ApproachCircle: + return new LegacyApproachCircle(); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs index f7ba8b9fc4..24f9217a5f 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs @@ -9,5 +9,6 @@ namespace osu.Game.Rulesets.Osu.Skinning SliderBorder, SliderBall, SpinnerBackground, + StarBreakAdditive, } } diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs index cb769c31b8..24a660e69e 100644 --- a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs @@ -179,7 +179,7 @@ namespace osu.Game.Rulesets.Osu.Statistics return; // Todo: This should probably not be done like this. - float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.BeatmapInfo.BaseDifficulty.CircleSize - 5) / 5) / 2; + float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.Difficulty.CircleSize - 5) / 5) / 2; foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle))) { diff --git a/osu.Game.Rulesets.Osu/UI/AnyOrderHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/AnyOrderHitPolicy.cs new file mode 100644 index 0000000000..b4de91562b --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/AnyOrderHitPolicy.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.UI +{ + /// + /// An which allows hitobjects to be hit in any order. + /// + public class AnyOrderHitPolicy : IHitPolicy + { + public IHitObjectContainer HitObjectContainer { get; set; } + + public bool IsHittable(DrawableHitObject hitObject, double time) => true; + + public void HandleHit(DrawableHitObject hitObject) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 7f86e9daf7..62cab4d6d7 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -26,6 +26,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { private const int max_sprites = 2048; + /// + /// An exponentiating factor to ease the trail fade. + /// + protected virtual float FadeExponent => 1.7f; + private readonly TrailPart[] parts = new TrailPart[max_sprites]; private int currentIndex; private IShader shader; @@ -133,6 +138,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor protected virtual bool InterpolateMovements => true; protected virtual float IntervalMultiplier => 1.0f; + protected virtual bool AvoidDrawingNearCursor => false; private Vector2? lastPosition; private readonly InputResampler resampler = new InputResampler(); @@ -141,43 +147,45 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor protected override bool OnMouseMove(MouseMoveEvent e) { - Vector2 pos = e.ScreenSpaceMousePosition; + AddTrail(e.ScreenSpaceMousePosition); + return base.OnMouseMove(e); + } - if (lastPosition == null) + protected void AddTrail(Vector2 position) + { + if (InterpolateMovements) { - lastPosition = pos; - resampler.AddPosition(lastPosition.Value); - return base.OnMouseMove(e); - } - - foreach (Vector2 pos2 in resampler.AddPosition(pos)) - { - Trace.Assert(lastPosition.HasValue); - - if (InterpolateMovements) + if (!lastPosition.HasValue) { - // ReSharper disable once PossibleInvalidOperationException + lastPosition = position; + resampler.AddPosition(lastPosition.Value); + return; + } + + foreach (Vector2 pos2 in resampler.AddPosition(position)) + { + Trace.Assert(lastPosition.HasValue); + Vector2 pos1 = lastPosition.Value; Vector2 diff = pos2 - pos1; float distance = diff.Length; Vector2 direction = diff / distance; float interval = partSize.X / 2.5f * IntervalMultiplier; + float stopAt = distance - (AvoidDrawingNearCursor ? interval : 0); - for (float d = interval; d < distance; d += interval) + for (float d = interval; d < stopAt; d += interval) { lastPosition = pos1 + direction * d; addPart(lastPosition.Value); } } - else - { - lastPosition = pos2; - addPart(lastPosition.Value); - } } - - return base.OnMouseMove(e); + else + { + lastPosition = position; + addPart(lastPosition.Value); + } } private void addPart(Vector2 screenSpacePosition) @@ -206,10 +214,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private Texture texture; private float time; + private float fadeExponent; private readonly TrailPart[] parts = new TrailPart[max_sprites]; private Vector2 size; - private Vector2 originPosition; private readonly QuadBatch vertexBatch = new QuadBatch(max_sprites, 1); @@ -227,6 +235,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor texture = Source.texture; size = Source.partSize; time = Source.time; + fadeExponent = Source.FadeExponent; originPosition = Vector2.Zero; @@ -249,6 +258,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor shader.Bind(); shader.GetUniform("g_FadeClock").UpdateValue(ref time); + shader.GetUniform("g_FadeExponent").UpdateValue(ref fadeExponent); texture.TextureGL.Bind(); diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 5812e8cf75..d1d9ee9f4d 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets.Osu.Configuration; @@ -42,12 +43,16 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor InternalChild = fadeContainer = new Container { RelativeSizeAxes = Axes.Both, - Child = cursorTrail = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling) + Children = new[] + { + cursorTrail = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling), + new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.CursorParticles), confineMode: ConfineMode.NoScaling), + } }; } [Resolved(canBeNull: true)] - private GameplayBeatmap beatmap { get; set; } + private GameplayState state { get; set; } [Resolved] private OsuConfigManager config { get; set; } @@ -91,10 +96,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { float scale = userCursorScale.Value; - if (autoCursorScale.Value && beatmap != null) + if (autoCursorScale.Value && state != null) { // if we have a beatmap available, let's get its circle size to figure out an automatic cursor scale modifier. - scale *= GetScaleForCircleSize(beatmap.BeatmapInfo.BaseDifficulty.CircleSize); + scale *= GetScaleForCircleSize(state.Beatmap.Difficulty.CircleSize); } cursorScale.Value = scale; @@ -115,9 +120,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor (ActiveCursor as OsuCursor)?.Contract(); } - public bool OnPressed(OsuAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case OsuAction.LeftButton: case OsuAction.RightButton: @@ -129,9 +134,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor return false; } - public void OnReleased(OsuAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - switch (action) + switch (e.Action) { case OsuAction.LeftButton: case OsuAction.RightButton: diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 8993a9b18a..2233a547b9 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -24,6 +24,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.UI { + [Cached] public class OsuPlayfield : Playfield { private readonly PlayfieldBorder playfieldBorder; @@ -104,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.UI private void onJudgementLoaded(DrawableOsuJudgement judgement) { - judgementAboveHitObjectLayer.Add(judgement.GetProxyAboveHitObjectsContent()); + judgementAboveHitObjectLayer.Add(judgement.ProxiedAboveHitObjectsContent); } [BackgroundDependencyLoader(true)] @@ -150,6 +151,10 @@ namespace osu.Game.Rulesets.Osu.UI DrawableOsuJudgement explosion = poolDictionary[result.Type].Get(doj => doj.Apply(result, judgedObject)); judgementLayer.Add(explosion); + + // the proxied content is added to judgementAboveHitObjectLayer once, on first load, and never removed from it. + // ensure that ordering is consistent with expectations (latest judgement should be front-most). + judgementAboveHitObjectLayer.ChangeChildDepth(explosion.ProxiedAboveHitObjectsContent, (float)-result.TimeAbsolute); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObjectContainer.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index 27d48d1296..4d4340936d 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -89,9 +89,9 @@ namespace osu.Game.Rulesets.Osu.UI base.OnHoverLost(e); } - public bool OnPressed(OsuAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case OsuAction.LeftButton: case OsuAction.RightButton: @@ -106,7 +106,7 @@ namespace osu.Game.Rulesets.Osu.UI return false; } - public void OnReleased(OsuAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } 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 index 06b964a647..bfd6ac3ad3 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -2,7 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; using osuTK; namespace osu.Game.Rulesets.Osu.Utils @@ -100,5 +104,47 @@ namespace osu.Game.Rulesets.Osu.Utils initial.Length * MathF.Sin(finalAngleRad) ); } + + /// + /// Reflects the position of the in the playfield horizontally. + /// + /// The object to reflect. + public static void ReflectHorizontally(OsuHitObject osuObject) + { + osuObject.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - osuObject.X, osuObject.Position.Y); + + if (!(osuObject is Slider slider)) + return; + + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); + + var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); + foreach (var point in controlPoints) + point.Position = new Vector2(-point.Position.X, point.Position.Y); + + slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + } + + /// + /// Reflects the position of the in the playfield vertically. + /// + /// The object to reflect. + public static void ReflectVertically(OsuHitObject osuObject) + { + osuObject.Position = new Vector2(osuObject.Position.X, OsuPlayfield.BASE_SIZE.Y - osuObject.Y); + + if (!(osuObject is Slider slider)) + return; + + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); + + var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); + foreach (var point in controlPoints) + point.Position = new Vector2(point.Position.X, -point.Position.Y); + + slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs index f048cad18c..6d4cac0ebe 100644 --- a/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.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.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Scoring; @@ -37,6 +38,6 @@ namespace osu.Game.Rulesets.Taiko.Tests Result.Type = Type; } - public override bool OnPressed(TaikoAction action) => false; + public override bool OnPressed(KeyBindingPressEvent e) => false; } } diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs index 829bcf34a1..ea877c9e17 100644 --- a/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; @@ -30,6 +31,6 @@ namespace osu.Game.Rulesets.Taiko.Tests nestedStrongHit.Result.Type = hitBoth ? Type : HitResult.Miss; } - public override bool OnPressed(TaikoAction action) => false; + public override bool OnPressed(KeyBindingPressEvent e) => false; } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs index f9b8e9a985..269a855219 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Origin = Anchor.Centre, Children = new Drawable[] { - new TaikoPlayfield(new ControlPointInfo()), + new TaikoPlayfield(), hoc = new ScrollingHitObjectContainer() } }; @@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Origin = Anchor.Centre, Children = new Drawable[] { - new TaikoPlayfield(new ControlPointInfo()), + new TaikoPlayfield(), hoc = new ScrollingHitObjectContainer() } }; diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs index 055a292fe8..24db046748 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Taiko.UI; using osuTK; @@ -17,6 +16,13 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [BackgroundDependencyLoader] private void load() { + var playfield = new TaikoPlayfield(); + + var beatmap = CreateWorkingBeatmap(new TaikoRuleset().RulesetInfo).GetPlayableBeatmap(new TaikoRuleset().RulesetInfo); + + foreach (var h in beatmap.HitObjects) + playfield.Add(h); + SetContents(_ => new TaikoInputManager(new TaikoRuleset().RulesetInfo) { RelativeSizeAxes = Axes.Both, @@ -25,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200), - Child = new InputDrum(new ControlPointInfo()) + Child = new InputDrum(playfield.HitObjectContainer) } }); } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs index f96297a06d..6f2fcd08f1 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Beatmap.Value.Track.Start(); }); - AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield(new ControlPointInfo()) + AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index dd3c6b317a..4b0b74ad27 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; - [TestCase(2.2867022617692685d, "diffcalc-test")] - [TestCase(2.2867022617692685d, "diffcalc-test-strong")] + [TestCase(2.2420075288523802d, "diffcalc-test")] + [TestCase(2.2420075288523802d, "diffcalc-test-strong")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(3.1704781712282624d, "diffcalc-test")] - [TestCase(3.1704781712282624d, "diffcalc-test-strong")] + [TestCase(3.134084469440479d, "diffcalc-test")] + [TestCase(3.134084469440479d, "diffcalc-test-strong")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new TaikoModDoubleTime()); 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 532fdc5cb0..b9b295767e 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs b/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs deleted file mode 100644 index e4dc261363..0000000000 --- a/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Skinning; - -namespace osu.Game.Rulesets.Taiko.Audio -{ - /// - /// Stores samples for the input drum. - /// The lifetime of the samples is adjusted so that they are only alive during the appropriate sample control point. - /// - public class DrumSampleContainer : LifetimeManagementContainer - { - private readonly ControlPointInfo controlPoints; - private readonly Dictionary mappings = new Dictionary(); - - private readonly IBindableList samplePoints = new BindableList(); - - public DrumSampleContainer(ControlPointInfo controlPoints) - { - this.controlPoints = controlPoints; - } - - [BackgroundDependencyLoader] - private void load() - { - samplePoints.BindTo(controlPoints.SamplePoints); - samplePoints.BindCollectionChanged((_, __) => recreateMappings(), true); - } - - private void recreateMappings() - { - mappings.Clear(); - ClearInternal(); - - SampleControlPoint[] points = samplePoints.Count == 0 - ? new[] { controlPoints.SamplePointAt(double.MinValue) } - : samplePoints.ToArray(); - - for (int i = 0; i < points.Length; i++) - { - var samplePoint = points[i]; - - var lifetimeStart = i > 0 ? samplePoint.Time : double.MinValue; - var lifetimeEnd = i + 1 < points.Length ? points[i + 1].Time : double.MaxValue; - - AddInternal(mappings[samplePoint.Time] = new DrumSample(samplePoint) - { - LifetimeStart = lifetimeStart, - LifetimeEnd = lifetimeEnd - }); - } - } - - public DrumSample SampleAt(double time) => mappings[controlPoints.SamplePointAt(time).Time]; - - public class DrumSample : CompositeDrawable - { - public override bool RemoveWhenNotAlive => false; - - public PausableSkinnableSound Centre { get; private set; } - public PausableSkinnableSound Rim { get; private set; } - - private readonly SampleControlPoint samplePoint; - - private Bindable sampleBank; - private BindableNumber sampleVolume; - - public DrumSample(SampleControlPoint samplePoint) - { - this.samplePoint = samplePoint; - } - - [BackgroundDependencyLoader] - private void load() - { - sampleBank = samplePoint.SampleBankBindable.GetBoundCopy(); - sampleBank.BindValueChanged(_ => recreate()); - - sampleVolume = samplePoint.SampleVolumeBindable.GetBoundCopy(); - sampleVolume.BindValueChanged(_ => recreate()); - - recreate(); - } - - private void recreate() - { - InternalChildren = new Drawable[] - { - Centre = new PausableSkinnableSound(samplePoint.GetSampleInfo()), - Rim = new PausableSkinnableSound(samplePoint.GetSampleInfo(HitSampleInfo.HIT_CLAP)) - }; - } - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index 90c99316b1..50c0ca7f55 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Utils; using System.Threading; +using JetBrains.Annotations; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; @@ -18,12 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps { internal class TaikoBeatmapConverter : BeatmapConverter { - /// - /// osu! is generally slower than taiko, so a factor is added to increase - /// speed. This must be used everywhere slider length or beat length is used. - /// - public const float LEGACY_VELOCITY_MULTIPLIER = 1.4f; - /// /// Because swells are easier in taiko than spinners are in osu!, /// legacy taiko multiplies a factor when converting the number of required hits. @@ -52,10 +47,11 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps protected override Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) { - // Rewrite the beatmap info to add the slider velocity multiplier - original.BeatmapInfo = original.BeatmapInfo.Clone(); - original.BeatmapInfo.BaseDifficulty = original.BeatmapInfo.BaseDifficulty.Clone(); - original.BeatmapInfo.BaseDifficulty.SliderMultiplier *= LEGACY_VELOCITY_MULTIPLIER; + if (!(original.Difficulty is TaikoMultiplierAppliedDifficulty)) + { + // Rewrite the beatmap info to add the slider velocity multiplier + original.Difficulty = new TaikoMultiplierAppliedDifficulty(original.Difficulty); + } Beatmap converted = base.ConvertBeatmap(original, cancellationToken); @@ -112,7 +108,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps StartTime = obj.StartTime, Samples = obj.Samples, Duration = taikoDuration, - TickRate = beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate == 3 ? 3 : 4 + TickRate = beatmap.Difficulty.SliderTickRate == 3 ? 3 : 4 }; } @@ -121,7 +117,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps case IHasDuration endTimeData: { - double hitMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty, 3, 5, 7.5) * swell_hit_multiplier; + double hitMultiplier = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.OverallDifficulty, 3, 5, 7.5) * swell_hit_multiplier; yield return new Swell { @@ -155,7 +151,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps // The true distance, accounting for any repeats. This ends up being the drum roll distance later int spans = (obj as IHasRepeats)?.SpanCount() ?? 1; - double distance = distanceData.Distance * spans * LEGACY_VELOCITY_MULTIPLIER; + double distance = distanceData.Distance * spans * LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime); @@ -168,10 +164,10 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps else beatLength = timingPoint.BeatLength / difficultyPoint.SpeedMultiplier; - double sliderScoringPointDistance = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate; + double sliderScoringPointDistance = osu_base_scoring_distance * beatmap.Difficulty.SliderMultiplier / beatmap.Difficulty.SliderTickRate; // The velocity and duration of the taiko hit object - calculated as the velocity of a drum roll. - double taikoVelocity = sliderScoringPointDistance * beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate; + double taikoVelocity = sliderScoringPointDistance * beatmap.Difficulty.SliderTickRate; taikoDuration = (int)(distance / taikoVelocity * beatLength); if (isForCurrentRuleset) @@ -187,12 +183,43 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps beatLength = timingPoint.BeatLength; // If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat - tickSpacing = Math.Min(beatLength / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate, (double)taikoDuration / spans); + tickSpacing = Math.Min(beatLength / beatmap.Difficulty.SliderTickRate, (double)taikoDuration / spans); return tickSpacing > 0 && distance / osuVelocity * 1000 < 2 * beatLength; } protected override Beatmap CreateBeatmap() => new TaikoBeatmap(); + + private class TaikoMultiplierAppliedDifficulty : BeatmapDifficulty + { + public TaikoMultiplierAppliedDifficulty(IBeatmapDifficultyInfo difficulty) + { + CopyFrom(difficulty); + } + + [UsedImplicitly] + public TaikoMultiplierAppliedDifficulty() + { + } + + #region Overrides of BeatmapDifficulty + + public override void CopyTo(BeatmapDifficulty other) + { + base.CopyTo(other); + if (!(other is TaikoMultiplierAppliedDifficulty)) + SliderMultiplier /= LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; + } + + public override void CopyFrom(IBeatmapDifficultyInfo other) + { + base.CopyFrom(other); + if (!(other is TaikoMultiplierAppliedDifficulty)) + SliderMultiplier *= LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; + } + + #endregion + } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs index 769d021362..0c17ca66b9 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// /// Calculates the colour coefficient of taiko difficulty. /// - public class Colour : StrainSkill + public class Colour : StrainDecaySkill { protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 0.4; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index a32f6ebe0d..973e55f4b4 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// /// Calculates the rhythm coefficient of taiko difficulty. /// - public class Rhythm : StrainSkill + public class Rhythm : StrainDecaySkill { protected override double SkillMultiplier => 10; protected override double StrainDecayBase => 0; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 4cceadb23f..54cf233d69 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// /// The reference play style chosen uses two hands, with full alternating (the hand changes after every hit). /// - public class Stamina : StrainSkill + public class Stamina : StrainDecaySkill { protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 0.4; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 18d06c069f..7dd47e804b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -85,7 +85,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty starRating = rescale(starRating); HitWindows hitWindows = new TaikoHitWindows(); - hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); return new TaikoDifficultyAttributes { @@ -94,8 +94,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StaminaStrain = staminaRating, RhythmStrain = rhythmRating, ColourStrain = colourRating, - // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future - GreatHitWindow = (int)hitWindows.WindowFor(HitResult.Great) / clockRate, + GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, MaxCombo = beatmap.HitObjects.Count(h => h is Hit), Skills = skills }; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 6117ed1673..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,10 +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); + 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/TaikoModClassic.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs index 5a4d18be98..6520517039 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs @@ -2,10 +2,30 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModClassic : ModClassic + public class TaikoModClassic : ModClassic, IApplicableToDrawableRuleset, IUpdatableByPlayfield { + private DrawableTaikoRuleset drawableTaikoRuleset; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableTaikoRuleset = (DrawableTaikoRuleset)drawableRuleset; + } + + public void Update(Playfield playfield) + { + // Classic taiko scrolls at a constant 100px per 1000ms. More notes become visible as the playfield is lengthened. + const float scroll_rate = 10; + + // Since the time range will depend on a positional value, it is referenced to the x480 pixel space. + float ratio = drawableTaikoRuleset.DrawHeight / 480; + + drawableTaikoRuleset.TimeRange.Value = (playfield.HitObjectContainer.DrawWidth / ratio) * scroll_rate; + } } } 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/TaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs index 1253b7c8ae..0a325f174e 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs @@ -36,24 +36,27 @@ namespace osu.Game.Rulesets.Taiko.Mods public TaikoFlashlight(TaikoPlayfield taikoPlayfield) { this.taikoPlayfield = taikoPlayfield; - FlashlightSize = new Vector2(0, getSizeFor(0)); + FlashlightSize = getSizeFor(0); AddLayout(flashlightProperties); } - private float getSizeFor(int combo) + private Vector2 getSizeFor(int combo) { + float size = default_flashlight_size; + if (combo > 200) - return default_flashlight_size * 0.8f; + size *= 0.8f; else if (combo > 100) - return default_flashlight_size * 0.9f; - else - return default_flashlight_size; + size *= 0.9f; + + // Preserve flashlight size through the playfield's aspect adjustment. + return new Vector2(0, size * taikoPlayfield.DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); } protected override void OnComboChange(ValueChangedEvent e) { - this.TransformTo(nameof(FlashlightSize), new Vector2(0, getSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), getSizeFor(e.NewValue), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; @@ -64,7 +67,11 @@ namespace osu.Game.Rulesets.Taiko.Mods if (!flashlightProperties.IsValid) { - FlashlightPosition = taikoPlayfield.HitTarget.ToSpaceOfOtherDrawable(taikoPlayfield.HitTarget.OriginPosition, this); + FlashlightPosition = ToLocalSpace(taikoPlayfield.HitTarget.ScreenSpaceDrawQuad.Centre); + + ClearTransforms(targetMember: nameof(FlashlightSize)); + FlashlightSize = getSizeFor(Combo.Value); + flashlightProperties.Validate(); } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs index 0fd3625a93..7f565cb82d 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs @@ -2,57 +2,55 @@ // 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; using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModHidden : ModHidden, IApplicableToDifficulty + public class TaikoModHidden : ModHidden, IApplicableToDrawableRuleset { public override string Description => @"Beats fade out before you hit them!"; public override double ScoreMultiplier => 1.06; /// - /// 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. + /// How far away from the hit target should hitobjects start to fade out. + /// Range: [0, 1] /// - private const double hd_sv_scale = (4.0 / 3.0 - 1.0 / 3.0) / (16.0 / 9.0 - 1.0 / 3.0); + private const float fade_out_start_time = 1f; - private double originalSliderMultiplier; + /// + /// How long hitobjects take to fade out, in terms of the scrolling length. + /// Range: [0, 1] + /// + private const float fade_out_duration = 0.375f; - private ControlPointInfo controlPointInfo; + private DrawableTaikoRuleset drawableRuleset; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + this.drawableRuleset = (DrawableTaikoRuleset)drawableRuleset; + } 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; + double preempt = drawableRuleset.TimeRange.Value / drawableRuleset.ControlPointAt(hitObject.HitObject.StartTime).Multiplier; + double start = hitObject.HitObject.StartTime - preempt * fade_out_start_time; + double duration = preempt * fade_out_duration; using (hitObject.BeginAbsoluteSequence(start)) { @@ -68,26 +66,5 @@ namespace osu.Game.Rulesets.Taiko.Mods 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/TaikoModMuted.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.cs new file mode 100644 index 0000000000..0f1e0b2885 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModMuted : ModMuted + { + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index d066abf767..521189d36c 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -11,6 +11,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -112,7 +113,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollBody), _ => new ElongatedCirclePiece()); - public override bool OnPressed(TaikoAction action) => false; + public override bool OnPressed(KeyBindingPressEvent e) => false; private void onNewResult(DrawableHitObject obj, JudgementResult result) { @@ -196,7 +197,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables ApplyResult(r => r.Type = ParentHitObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); } - public override bool OnPressed(TaikoAction action) => false; + public override bool OnPressed(KeyBindingPressEvent e) => false; } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index 0df45c424d..dc2ed200a1 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -4,6 +4,7 @@ using System; using JetBrains.Annotations; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; @@ -61,9 +62,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } } - public override bool OnPressed(TaikoAction action) + public override bool OnPressed(KeyBindingPressEvent e) { - JudgementType = action == TaikoAction.LeftRim || action == TaikoAction.RightRim ? HitType.Rim : HitType.Centre; + JudgementType = e.Action == TaikoAction.LeftRim || e.Action == TaikoAction.RightRim ? HitType.Rim : HitType.Centre; return UpdateResult(true); } @@ -91,7 +92,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables ApplyResult(r => r.Type = ParentHitObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); } - public override bool OnPressed(TaikoAction action) => false; + public override bool OnPressed(KeyBindingPressEvent e) => false; } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 1e9fc187eb..97adc5f197 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -8,6 +8,7 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; @@ -145,19 +146,19 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables ApplyResult(r => r.Type = result); } - public override bool OnPressed(TaikoAction action) + public override bool OnPressed(KeyBindingPressEvent e) { if (pressHandledThisFrame) return true; if (Judged) return false; - validActionPressed = HitActions.Contains(action); + validActionPressed = HitActions.Contains(e.Action); // Only count this as handled if the new judgement is a hit var result = UpdateResult(true); if (IsHit) - HitAction = action; + HitAction = e.Action; // Regardless of whether we've hit or not, any secondary key presses in the same frame should be discarded // E.g. hitting a non-strong centre as a strong should not fall through and perform a hit on the next note @@ -165,11 +166,11 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return result; } - public override void OnReleased(TaikoAction action) + public override void OnReleased(KeyBindingReleaseEvent e) { - if (action == HitAction) + if (e.Action == HitAction) HitAction = null; - base.OnReleased(action); + base.OnReleased(e); } protected override void Update() @@ -265,7 +266,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables ApplyResult(r => r.Type = r.Judgement.MaxResult); } - public override bool OnPressed(TaikoAction action) + public override bool OnPressed(KeyBindingPressEvent e) { // Don't process actions until the main hitobject is hit if (!ParentHitObject.IsHit) @@ -276,7 +277,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return false; // Don't handle invalid hit action presses - if (!ParentHitObject.HitActions.Contains(action)) + if (!ParentHitObject.HitActions.Contains(e.Action)) return false; return UpdateResult(true); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 60f9521996..2d19296d06 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -12,6 +12,7 @@ using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; @@ -227,7 +228,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); } @@ -266,13 +267,13 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private bool? lastWasCentre; - public override bool OnPressed(TaikoAction action) + public override bool OnPressed(KeyBindingPressEvent e) { // Don't handle keys before the swell starts if (Time.Current < HitObject.StartTime) return false; - var isCentre = action == TaikoAction.LeftCentre || action == TaikoAction.RightCentre; + var isCentre = e.Action == TaikoAction.LeftCentre || e.Action == TaikoAction.RightCentre; // Ensure alternating centre and rim hits if (lastWasCentre == isCentre) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs index 47fc7e5ab3..d4ea9ed29f 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; @@ -34,7 +35,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { } - public override bool OnPressed(TaikoAction action) => false; + public override bool OnPressed(KeyBindingPressEvent e) => false; protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollTick), _ => new TickPiece()); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 6a8d8a611c..eb64ba72f2 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; @@ -76,9 +77,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// public Drawable CreateProxiedContent() => proxiedContent.CreateProxy(); - public abstract bool OnPressed(TaikoAction action); + public abstract bool OnPressed(KeyBindingPressEvent e); - public virtual void OnReleased(TaikoAction action) + public virtual void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index c0377c67a5..0318e32991 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -6,10 +6,10 @@ using System; using System.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Judgements; using osuTK; @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Taiko.Objects private float overallDifficulty = BeatmapDifficulty.DEFAULT_DIFFICULTY; - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); @@ -120,7 +120,7 @@ namespace osu.Game.Rulesets.Taiko.Objects double IHasDistance.Distance => Duration * Velocity; SliderPath IHasPath.Path - => new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(1) }, ((IHasDistance)this).Distance / TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER); + => new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(1) }, ((IHasDistance)this).Distance / LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER); #endregion } diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs index f7a1d130eb..0d6ce44255 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs @@ -40,8 +40,8 @@ namespace osu.Game.Rulesets.Taiko.Scoring { base.ApplyBeatmap(beatmap); - hpMultiplier = 1 / (object_count_factor * Math.Max(1, beatmap.HitObjects.OfType().Count()) * BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.5, 0.75, 0.98)); - hpMissMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.0018, 0.0075, 0.0120); + hpMultiplier = 1 / (object_count_factor * Math.Max(1, beatmap.HitObjects.OfType().Count()) * IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.5, 0.75, 0.98)); + hpMissMultiplier = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.0018, 0.0075, 0.0120); } protected override double GetHealthIncreaseFor(JudgementResult result) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs index 795885d4b9..86be40dea8 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs @@ -7,7 +7,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Taiko.Audio; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; using osu.Game.Skinning; using osuTK; @@ -111,7 +113,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public readonly Sprite Centre; [Resolved] - private DrumSampleContainer sampleContainer { get; set; } + private DrumSampleTriggerSource sampleTriggerSource { get; set; } public LegacyHalfDrum(bool flipped) { @@ -140,20 +142,19 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy Centre.Texture = skin.GetTexture(@"taiko-drum-inner"); } - public bool OnPressed(TaikoAction action) + public bool OnPressed(KeyBindingPressEvent e) { Drawable target = null; - var drumSample = sampleContainer.SampleAt(Time.Current); - if (action == CentreAction) + if (e.Action == CentreAction) { target = Centre; - drumSample.Centre?.Play(); + sampleTriggerSource.Play(HitType.Centre); } - else if (action == RimAction) + else if (e.Action == RimAction) { target = Rim; - drumSample.Rim?.Play(); + sampleTriggerSource.Play(HitType.Rim); } if (target != null) @@ -173,7 +174,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy return false; } - public void OnReleased(TaikoAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs index 6fc59ea0e8..fa49242675 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs @@ -25,10 +25,10 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy } [BackgroundDependencyLoader(true)] - private void load(GameplayBeatmap gameplayBeatmap) + private void load(GameplayState gameplayState) { - if (gameplayBeatmap != null) - ((IBindable)LastResult).BindTo(gameplayBeatmap.LastJudgementResult); + if (gameplayState != null) + ((IBindable)LastResult).BindTo(gameplayState.LastJudgementResult); } private bool passing; diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index ab5fcf6336..adc924ba38 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -149,7 +149,8 @@ namespace osu.Game.Rulesets.Taiko case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()), + new TaikoModMuted(), }; default: diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs index 1ad1e4495c..876fa207bf 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.UI { @@ -16,5 +17,26 @@ namespace osu.Game.Rulesets.Taiko.UI this.MoveToY(-100, 500); base.ApplyHitAnimations(); } + + protected override Drawable CreateDefaultJudgement(HitResult result) => new TaikoJudgementPiece(result); + + private class TaikoJudgementPiece : DefaultJudgementPiece + { + public TaikoJudgementPiece(HitResult result) + : base(result) + { + } + + public override void PlayAnimation() + { + if (Result != HitResult.Miss) + { + JudgementText.ScaleTo(0.9f); + JudgementText.ScaleTo(1, 500, Easing.OutElastic); + } + + base.PlayAnimation(); + } + } } } diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs index 6a16f311bf..e1063e1071 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Taiko.UI } [BackgroundDependencyLoader(true)] - private void load(TextureStore textures, GameplayBeatmap gameplayBeatmap) + private void load(TextureStore textures, GameplayState gameplayState) { InternalChildren = new[] { @@ -49,8 +49,8 @@ namespace osu.Game.Rulesets.Taiko.UI animations[TaikoMascotAnimationState.Fail] = new TaikoMascotAnimation(TaikoMascotAnimationState.Fail), }; - if (gameplayBeatmap != null) - ((IBindable)LastResult).BindTo(gameplayBeatmap.LastJudgementResult); + if (gameplayState != null) + ((IBindable)LastResult).BindTo(gameplayState.LastJudgementResult); } protected override void LoadComplete() diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index ed8e6859a2..824b95639b 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.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 System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Drawables; @@ -15,6 +17,7 @@ using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Timing; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Scoring; using osu.Game.Skinning; @@ -24,12 +27,14 @@ namespace osu.Game.Rulesets.Taiko.UI { public class DrawableTaikoRuleset : DrawableScrollingRuleset { - private SkinnableDrawable scroller; + public new BindableDouble TimeRange => base.TimeRange; protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping; protected override bool UserScrollSpeedAdjustment => false; + private SkinnableDrawable scroller; + public DrawableTaikoRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { @@ -57,11 +62,19 @@ namespace osu.Game.Rulesets.Taiko.UI scroller.Height = ToLocalSpace(playfieldScreen.TopLeft + new Vector2(0, playfieldScreen.Height / 20)).Y; } + public MultiplierControlPoint ControlPointAt(double time) + { + int result = ControlPoints.BinarySearch(new MultiplierControlPoint(time)); + if (result < 0) + result = Math.Clamp(~result - 1, 0, ControlPoints.Count); + return ControlPoints[result]; + } + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new TaikoPlayfieldAdjustmentContainer(); protected override PassThroughInputManager CreateInputManager() => new TaikoInputManager(Ruleset.RulesetInfo); - protected override Playfield CreatePlayfield() => new TaikoPlayfield(Beatmap.ControlPointInfo); + protected override Playfield CreatePlayfield() => new TaikoPlayfield(); public override DrawableHitObject CreateDrawableRepresentation(TaikoHitObject h) => null; diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs new file mode 100644 index 0000000000..3279d128d3 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Audio; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public class DrumSampleTriggerSource : GameplaySampleTriggerSource + { + public DrumSampleTriggerSource(HitObjectContainer hitObjectContainer) + : base(hitObjectContainer) + { + } + + public void Play(HitType hitType) + { + var hitObject = GetMostValidObject(); + + if (hitObject == null) + return; + + PlaySamples(new ISampleInfo[] { hitObject.SampleControlPoint.GetSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL) }); + } + + public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead"); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index 1ca1be1bdf..861b800038 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -2,18 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; -using osu.Game.Beatmaps.ControlPoints; +using osu.Framework.Input.Events; using osu.Game.Graphics; -using osu.Game.Rulesets.Taiko.Audio; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.UI { @@ -25,11 +26,11 @@ namespace osu.Game.Rulesets.Taiko.UI private const float middle_split = 0.025f; [Cached] - private DrumSampleContainer sampleContainer; + private DrumSampleTriggerSource sampleTriggerSource; - public InputDrum(ControlPointInfo controlPoints) + public InputDrum(HitObjectContainer hitObjectContainer) { - sampleContainer = new DrumSampleContainer(controlPoints); + sampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer); RelativeSizeAxes = Axes.Both; } @@ -70,7 +71,7 @@ namespace osu.Game.Rulesets.Taiko.UI } } }), - sampleContainer + sampleTriggerSource }; } @@ -95,7 +96,7 @@ namespace osu.Game.Rulesets.Taiko.UI private readonly Sprite centreHit; [Resolved] - private DrumSampleContainer sampleContainer { get; set; } + private DrumSampleTriggerSource sampleTriggerSource { get; set; } public TaikoHalfDrum(bool flipped) { @@ -151,26 +152,24 @@ namespace osu.Game.Rulesets.Taiko.UI [Resolved(canBeNull: true)] private GameplayClock gameplayClock { get; set; } - public bool OnPressed(TaikoAction action) + public bool OnPressed(KeyBindingPressEvent e) { Drawable target = null; Drawable back = null; - var drumSample = sampleContainer.SampleAt(Time.Current); - - if (action == CentreAction) + if (e.Action == CentreAction) { target = centreHit; back = centre; - drumSample.Centre?.Play(); + sampleTriggerSource.Play(HitType.Centre); } - else if (action == RimAction) + else if (e.Action == RimAction) { target = rimHit; back = rim; - drumSample.Rim?.Play(); + sampleTriggerSource.Play(HitType.Rim); } if (target != null) @@ -197,7 +196,7 @@ namespace osu.Game.Rulesets.Taiko.UI return false; } - public void OnReleased(TaikoAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } } diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 46dafc3a30..d650cab729 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -8,7 +8,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Judgements; @@ -27,12 +26,10 @@ namespace osu.Game.Rulesets.Taiko.UI { public class TaikoPlayfield : ScrollingPlayfield { - private readonly ControlPointInfo controlPoints; - /// /// Default height of a when inside a . /// - public const float DEFAULT_HEIGHT = 178; + public const float DEFAULT_HEIGHT = 212; private Container hitExplosionContainer; private Container kiaiExplosionContainer; @@ -56,11 +53,6 @@ namespace osu.Game.Rulesets.Taiko.UI private Container hitTargetOffsetContent; - public TaikoPlayfield(ControlPointInfo controlPoints) - { - this.controlPoints = controlPoints; - } - [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -131,7 +123,7 @@ namespace osu.Game.Rulesets.Taiko.UI Children = new Drawable[] { new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()), - new InputDrum(controlPoints) + new InputDrum(HitObjectContainer) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, diff --git a/osu.Game.Tests/Beatmaps/BeatmapDifficultyCacheTest.cs b/osu.Game.Tests/Beatmaps/BeatmapDifficultyCacheTest.cs deleted file mode 100644 index d407c0663f..0000000000 --- a/osu.Game.Tests/Beatmaps/BeatmapDifficultyCacheTest.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using NUnit.Framework; -using osu.Game.Beatmaps; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; - -namespace osu.Game.Tests.Beatmaps -{ - [TestFixture] - public class BeatmapDifficultyCacheTest - { - [Test] - public void TestKeyEqualsWithDifferentModInstances() - { - var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); - var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); - - Assert.That(key1, Is.EqualTo(key2)); - } - - [Test] - public void TestKeyEqualsWithDifferentModOrder() - { - var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); - var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHidden(), new OsuModHardRock() }); - - Assert.That(key1, Is.EqualTo(key2)); - } - - [TestCase(1.3, DifficultyRating.Easy)] - [TestCase(1.993, DifficultyRating.Easy)] - [TestCase(1.998, DifficultyRating.Normal)] - [TestCase(2.4, DifficultyRating.Normal)] - [TestCase(2.693, DifficultyRating.Normal)] - [TestCase(2.698, DifficultyRating.Hard)] - [TestCase(3.5, DifficultyRating.Hard)] - [TestCase(3.993, DifficultyRating.Hard)] - [TestCase(3.997, DifficultyRating.Insane)] - [TestCase(5.0, DifficultyRating.Insane)] - [TestCase(5.292, DifficultyRating.Insane)] - [TestCase(5.297, DifficultyRating.Expert)] - [TestCase(6.2, DifficultyRating.Expert)] - [TestCase(6.493, DifficultyRating.Expert)] - [TestCase(6.498, DifficultyRating.ExpertPlus)] - [TestCase(8.3, DifficultyRating.ExpertPlus)] - public void TestDifficultyRatingMapping(double starRating, DifficultyRating expectedBracket) - { - var actualBracket = BeatmapDifficultyCache.GetDifficultyRating(starRating); - - Assert.AreEqual(expectedBracket, actualBracket); - } - } -} diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 0f82492e51..b5e1fa204f 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -3,15 +3,12 @@ using System; using System.IO; -using NUnit.Framework; -using osuTK; -using osuTK.Graphics; -using osu.Game.Tests.Resources; using System.Linq; +using NUnit.Framework; using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Beatmaps.Formats; +using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Timing; using osu.Game.IO; using osu.Game.Rulesets.Catch; @@ -19,9 +16,13 @@ using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Skinning; +using osu.Game.Tests.Resources; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Tests.Beatmaps.Formats { @@ -58,12 +59,14 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", metadata.AudioFile); Assert.AreEqual(0, beatmapInfo.AudioLeadIn); Assert.AreEqual(164471, metadata.PreviewTime); - Assert.IsFalse(beatmapInfo.Countdown); Assert.AreEqual(0.7f, beatmapInfo.StackLeniency); Assert.IsTrue(beatmapInfo.RulesetID == 0); Assert.IsFalse(beatmapInfo.LetterboxInBreaks); Assert.IsFalse(beatmapInfo.SpecialStyle); Assert.IsFalse(beatmapInfo.WidescreenStoryboard); + Assert.IsFalse(beatmapInfo.SamplesMatchPlaybackRate); + Assert.AreEqual(CountdownType.None, beatmapInfo.Countdown); + Assert.AreEqual(0, beatmapInfo.CountdownOffset); } } @@ -126,7 +129,7 @@ namespace osu.Game.Tests.Beatmaps.Formats using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) using (var stream = new LineBufferedReader(resStream)) { - var difficulty = decoder.Decode(stream).BeatmapInfo.BaseDifficulty; + var difficulty = decoder.Decode(stream).Difficulty; Assert.AreEqual(6.5f, difficulty.DrainRate); Assert.AreEqual(4, difficulty.CircleSize); @@ -165,7 +168,7 @@ namespace osu.Game.Tests.Beatmaps.Formats using (var stream = new LineBufferedReader(resStream)) { var beatmap = decoder.Decode(stream); - var controlPoints = beatmap.ControlPointInfo; + var controlPoints = (LegacyControlPointInfo)beatmap.ControlPointInfo; Assert.AreEqual(4, controlPoints.TimingPoints.Count); Assert.AreEqual(5, controlPoints.DifficultyPoints.Count); @@ -239,7 +242,7 @@ namespace osu.Game.Tests.Beatmaps.Formats using (var resStream = TestResources.OpenResource("overlapping-control-points.osu")) using (var stream = new LineBufferedReader(resStream)) { - var controlPoints = decoder.Decode(stream).ControlPointInfo; + var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo; Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(4)); Assert.That(controlPoints.DifficultyPoints.Count, Is.EqualTo(3)); @@ -323,12 +326,12 @@ namespace osu.Game.Tests.Beatmaps.Formats new OsuBeatmapProcessor(converted).PreProcess(); new OsuBeatmapProcessor(converted).PostProcess(); - Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndex); - Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndex); - Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndex); - Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndex); - Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndex); - Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndex); + Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets); + Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets); + Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets); + Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets); + Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets); + Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets); } } @@ -346,12 +349,12 @@ namespace osu.Game.Tests.Beatmaps.Formats new CatchBeatmapProcessor(converted).PreProcess(); new CatchBeatmapProcessor(converted).PostProcess(); - Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndex); - Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndex); - Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndex); - Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndex); - Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndex); - Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndex); + Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets); + Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets); + Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets); + Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets); + Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets); + Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets); } } @@ -665,111 +668,111 @@ namespace osu.Game.Tests.Beatmaps.Formats // Multi-segment var first = ((IHasPath)decoded.HitObjects[0]).Path; - Assert.That(first.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); - Assert.That(first.ControlPoints[0].Type.Value, Is.EqualTo(PathType.PerfectCurve)); - Assert.That(first.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(161, -244))); - Assert.That(first.ControlPoints[1].Type.Value, Is.EqualTo(null)); + Assert.That(first.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + Assert.That(first.ControlPoints[0].Type, Is.EqualTo(PathType.PerfectCurve)); + Assert.That(first.ControlPoints[1].Position, Is.EqualTo(new Vector2(161, -244))); + Assert.That(first.ControlPoints[1].Type, Is.EqualTo(null)); - Assert.That(first.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(376, -3))); - Assert.That(first.ControlPoints[2].Type.Value, Is.EqualTo(PathType.Bezier)); - Assert.That(first.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(68, 15))); - Assert.That(first.ControlPoints[3].Type.Value, Is.EqualTo(null)); - Assert.That(first.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(259, -132))); - Assert.That(first.ControlPoints[4].Type.Value, Is.EqualTo(null)); - Assert.That(first.ControlPoints[5].Position.Value, Is.EqualTo(new Vector2(92, -107))); - Assert.That(first.ControlPoints[5].Type.Value, Is.EqualTo(null)); + Assert.That(first.ControlPoints[2].Position, Is.EqualTo(new Vector2(376, -3))); + Assert.That(first.ControlPoints[2].Type, Is.EqualTo(PathType.Bezier)); + Assert.That(first.ControlPoints[3].Position, Is.EqualTo(new Vector2(68, 15))); + Assert.That(first.ControlPoints[3].Type, Is.EqualTo(null)); + Assert.That(first.ControlPoints[4].Position, Is.EqualTo(new Vector2(259, -132))); + Assert.That(first.ControlPoints[4].Type, Is.EqualTo(null)); + Assert.That(first.ControlPoints[5].Position, Is.EqualTo(new Vector2(92, -107))); + Assert.That(first.ControlPoints[5].Type, Is.EqualTo(null)); // Single-segment var second = ((IHasPath)decoded.HitObjects[1]).Path; - Assert.That(second.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); - Assert.That(second.ControlPoints[0].Type.Value, Is.EqualTo(PathType.PerfectCurve)); - Assert.That(second.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(161, -244))); - Assert.That(second.ControlPoints[1].Type.Value, Is.EqualTo(null)); - Assert.That(second.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(376, -3))); - Assert.That(second.ControlPoints[2].Type.Value, Is.EqualTo(null)); + Assert.That(second.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + Assert.That(second.ControlPoints[0].Type, Is.EqualTo(PathType.PerfectCurve)); + Assert.That(second.ControlPoints[1].Position, Is.EqualTo(new Vector2(161, -244))); + Assert.That(second.ControlPoints[1].Type, Is.EqualTo(null)); + Assert.That(second.ControlPoints[2].Position, Is.EqualTo(new Vector2(376, -3))); + Assert.That(second.ControlPoints[2].Type, Is.EqualTo(null)); // Implicit multi-segment var third = ((IHasPath)decoded.HitObjects[2]).Path; - Assert.That(third.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); - Assert.That(third.ControlPoints[0].Type.Value, Is.EqualTo(PathType.Bezier)); - Assert.That(third.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(0, 192))); - Assert.That(third.ControlPoints[1].Type.Value, Is.EqualTo(null)); - Assert.That(third.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(224, 192))); - Assert.That(third.ControlPoints[2].Type.Value, Is.EqualTo(null)); + Assert.That(third.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + Assert.That(third.ControlPoints[0].Type, Is.EqualTo(PathType.Bezier)); + Assert.That(third.ControlPoints[1].Position, Is.EqualTo(new Vector2(0, 192))); + Assert.That(third.ControlPoints[1].Type, Is.EqualTo(null)); + Assert.That(third.ControlPoints[2].Position, Is.EqualTo(new Vector2(224, 192))); + Assert.That(third.ControlPoints[2].Type, Is.EqualTo(null)); - Assert.That(third.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(224, 0))); - Assert.That(third.ControlPoints[3].Type.Value, Is.EqualTo(PathType.Bezier)); - Assert.That(third.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(224, -192))); - Assert.That(third.ControlPoints[4].Type.Value, Is.EqualTo(null)); - Assert.That(third.ControlPoints[5].Position.Value, Is.EqualTo(new Vector2(480, -192))); - Assert.That(third.ControlPoints[5].Type.Value, Is.EqualTo(null)); - Assert.That(third.ControlPoints[6].Position.Value, Is.EqualTo(new Vector2(480, 0))); - Assert.That(third.ControlPoints[6].Type.Value, Is.EqualTo(null)); + Assert.That(third.ControlPoints[3].Position, Is.EqualTo(new Vector2(224, 0))); + Assert.That(third.ControlPoints[3].Type, Is.EqualTo(PathType.Bezier)); + Assert.That(third.ControlPoints[4].Position, Is.EqualTo(new Vector2(224, -192))); + Assert.That(third.ControlPoints[4].Type, Is.EqualTo(null)); + Assert.That(third.ControlPoints[5].Position, Is.EqualTo(new Vector2(480, -192))); + Assert.That(third.ControlPoints[5].Type, Is.EqualTo(null)); + Assert.That(third.ControlPoints[6].Position, Is.EqualTo(new Vector2(480, 0))); + Assert.That(third.ControlPoints[6].Type, Is.EqualTo(null)); // Last control point duplicated var fourth = ((IHasPath)decoded.HitObjects[3]).Path; - Assert.That(fourth.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); - Assert.That(fourth.ControlPoints[0].Type.Value, Is.EqualTo(PathType.Bezier)); - Assert.That(fourth.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(1, 1))); - Assert.That(fourth.ControlPoints[1].Type.Value, Is.EqualTo(null)); - Assert.That(fourth.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(2, 2))); - Assert.That(fourth.ControlPoints[2].Type.Value, Is.EqualTo(null)); - Assert.That(fourth.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(3, 3))); - Assert.That(fourth.ControlPoints[3].Type.Value, Is.EqualTo(null)); - Assert.That(fourth.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(3, 3))); - Assert.That(fourth.ControlPoints[4].Type.Value, Is.EqualTo(null)); + Assert.That(fourth.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + Assert.That(fourth.ControlPoints[0].Type, Is.EqualTo(PathType.Bezier)); + Assert.That(fourth.ControlPoints[1].Position, Is.EqualTo(new Vector2(1, 1))); + Assert.That(fourth.ControlPoints[1].Type, Is.EqualTo(null)); + Assert.That(fourth.ControlPoints[2].Position, Is.EqualTo(new Vector2(2, 2))); + Assert.That(fourth.ControlPoints[2].Type, Is.EqualTo(null)); + Assert.That(fourth.ControlPoints[3].Position, Is.EqualTo(new Vector2(3, 3))); + Assert.That(fourth.ControlPoints[3].Type, Is.EqualTo(null)); + Assert.That(fourth.ControlPoints[4].Position, Is.EqualTo(new Vector2(3, 3))); + Assert.That(fourth.ControlPoints[4].Type, Is.EqualTo(null)); // Last control point in segment duplicated var fifth = ((IHasPath)decoded.HitObjects[4]).Path; - Assert.That(fifth.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); - Assert.That(fifth.ControlPoints[0].Type.Value, Is.EqualTo(PathType.Bezier)); - Assert.That(fifth.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(1, 1))); - Assert.That(fifth.ControlPoints[1].Type.Value, Is.EqualTo(null)); - Assert.That(fifth.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(2, 2))); - Assert.That(fifth.ControlPoints[2].Type.Value, Is.EqualTo(null)); - Assert.That(fifth.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(3, 3))); - Assert.That(fifth.ControlPoints[3].Type.Value, Is.EqualTo(null)); - Assert.That(fifth.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(3, 3))); - Assert.That(fifth.ControlPoints[4].Type.Value, Is.EqualTo(null)); + Assert.That(fifth.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + Assert.That(fifth.ControlPoints[0].Type, Is.EqualTo(PathType.Bezier)); + Assert.That(fifth.ControlPoints[1].Position, Is.EqualTo(new Vector2(1, 1))); + Assert.That(fifth.ControlPoints[1].Type, Is.EqualTo(null)); + Assert.That(fifth.ControlPoints[2].Position, Is.EqualTo(new Vector2(2, 2))); + Assert.That(fifth.ControlPoints[2].Type, Is.EqualTo(null)); + Assert.That(fifth.ControlPoints[3].Position, Is.EqualTo(new Vector2(3, 3))); + Assert.That(fifth.ControlPoints[3].Type, Is.EqualTo(null)); + Assert.That(fifth.ControlPoints[4].Position, Is.EqualTo(new Vector2(3, 3))); + Assert.That(fifth.ControlPoints[4].Type, Is.EqualTo(null)); - Assert.That(fifth.ControlPoints[5].Position.Value, Is.EqualTo(new Vector2(4, 4))); - Assert.That(fifth.ControlPoints[5].Type.Value, Is.EqualTo(PathType.Bezier)); - Assert.That(fifth.ControlPoints[6].Position.Value, Is.EqualTo(new Vector2(5, 5))); - Assert.That(fifth.ControlPoints[6].Type.Value, Is.EqualTo(null)); + Assert.That(fifth.ControlPoints[5].Position, Is.EqualTo(new Vector2(4, 4))); + Assert.That(fifth.ControlPoints[5].Type, Is.EqualTo(PathType.Bezier)); + Assert.That(fifth.ControlPoints[6].Position, Is.EqualTo(new Vector2(5, 5))); + Assert.That(fifth.ControlPoints[6].Type, Is.EqualTo(null)); // Implicit perfect-curve multi-segment(Should convert to bezier to match stable) var sixth = ((IHasPath)decoded.HitObjects[5]).Path; - Assert.That(sixth.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); - Assert.That(sixth.ControlPoints[0].Type.Value == PathType.Bezier); - Assert.That(sixth.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(75, 145))); - Assert.That(sixth.ControlPoints[1].Type.Value == null); - Assert.That(sixth.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(170, 75))); + Assert.That(sixth.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + Assert.That(sixth.ControlPoints[0].Type == PathType.Bezier); + Assert.That(sixth.ControlPoints[1].Position, Is.EqualTo(new Vector2(75, 145))); + Assert.That(sixth.ControlPoints[1].Type == null); + Assert.That(sixth.ControlPoints[2].Position, Is.EqualTo(new Vector2(170, 75))); - Assert.That(sixth.ControlPoints[2].Type.Value == PathType.Bezier); - Assert.That(sixth.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(300, 145))); - Assert.That(sixth.ControlPoints[3].Type.Value == null); - Assert.That(sixth.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(410, 20))); - Assert.That(sixth.ControlPoints[4].Type.Value == null); + Assert.That(sixth.ControlPoints[2].Type == PathType.Bezier); + Assert.That(sixth.ControlPoints[3].Position, Is.EqualTo(new Vector2(300, 145))); + Assert.That(sixth.ControlPoints[3].Type == null); + Assert.That(sixth.ControlPoints[4].Position, Is.EqualTo(new Vector2(410, 20))); + Assert.That(sixth.ControlPoints[4].Type == null); // Explicit perfect-curve multi-segment(Should not convert to bezier) var seventh = ((IHasPath)decoded.HitObjects[6]).Path; - Assert.That(seventh.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); - Assert.That(seventh.ControlPoints[0].Type.Value == PathType.PerfectCurve); - Assert.That(seventh.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(75, 145))); - Assert.That(seventh.ControlPoints[1].Type.Value == null); - Assert.That(seventh.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(170, 75))); + Assert.That(seventh.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + Assert.That(seventh.ControlPoints[0].Type == PathType.PerfectCurve); + Assert.That(seventh.ControlPoints[1].Position, Is.EqualTo(new Vector2(75, 145))); + Assert.That(seventh.ControlPoints[1].Type == null); + Assert.That(seventh.ControlPoints[2].Position, Is.EqualTo(new Vector2(170, 75))); - Assert.That(seventh.ControlPoints[2].Type.Value == PathType.PerfectCurve); - Assert.That(seventh.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(300, 145))); - Assert.That(seventh.ControlPoints[3].Type.Value == null); - Assert.That(seventh.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(410, 20))); - Assert.That(seventh.ControlPoints[4].Type.Value == null); + Assert.That(seventh.ControlPoints[2].Type == PathType.PerfectCurve); + Assert.That(seventh.ControlPoints[3].Position, Is.EqualTo(new Vector2(300, 145))); + Assert.That(seventh.ControlPoints[3].Type == null); + Assert.That(seventh.ControlPoints[4].Position, Is.EqualTo(new Vector2(410, 20))); + Assert.That(seventh.ControlPoints[4].Type == null); } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index 059432eeaf..896aa53f82 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -14,6 +14,7 @@ using osu.Framework.IO.Stores; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; +using osu.Game.Beatmaps.Legacy; using osu.Game.IO; using osu.Game.IO.Serialization; using osu.Game.Rulesets.Catch; @@ -49,6 +50,63 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.IsTrue(areComboColoursEqual(decodedAfterEncode.beatmapSkin.Configuration, decoded.beatmapSkin.Configuration)); } + [TestCaseSource(nameof(allBeatmaps))] + public void TestEncodeDecodeStabilityDoubleConvert(string name) + { + var decoded = decodeFromLegacy(beatmaps_resource_store.GetStream(name), name); + var decodedAfterEncode = decodeFromLegacy(encodeToLegacy(decoded), name); + + // run an extra convert. this is expected to be stable. + decodedAfterEncode.beatmap = convert(decodedAfterEncode.beatmap); + + sort(decoded.beatmap); + sort(decodedAfterEncode.beatmap); + + Assert.That(decodedAfterEncode.beatmap.Serialize(), Is.EqualTo(decoded.beatmap.Serialize())); + Assert.IsTrue(areComboColoursEqual(decodedAfterEncode.beatmapSkin.Configuration, decoded.beatmapSkin.Configuration)); + } + + [TestCaseSource(nameof(allBeatmaps))] + public void TestEncodeDecodeStabilityWithNonLegacyControlPoints(string name) + { + var decoded = decodeFromLegacy(beatmaps_resource_store.GetStream(name), name); + + // we are testing that the transfer of relevant data to hitobjects (from legacy control points) sticks through encode/decode. + // before the encode step, the legacy information is removed here. + decoded.beatmap.ControlPointInfo = removeLegacyControlPointTypes(decoded.beatmap.ControlPointInfo); + + var decodedAfterEncode = decodeFromLegacy(encodeToLegacy(decoded), name); + + // in this process, we may lose some detail in the control points section. + // let's focus on only the hitobjects. + var originalHitObjects = decoded.beatmap.HitObjects.Serialize(); + var newHitObjects = decodedAfterEncode.beatmap.HitObjects.Serialize(); + + Assert.That(newHitObjects, Is.EqualTo(originalHitObjects)); + + ControlPointInfo removeLegacyControlPointTypes(ControlPointInfo controlPointInfo) + { + // emulate non-legacy control points by cloning the non-legacy portion. + // the assertion is that the encoder can recreate this losslessly from hitobject data. + Assert.IsInstanceOf(controlPointInfo); + + var newControlPoints = new ControlPointInfo(); + + foreach (var point in controlPointInfo.AllControlPoints) + { + // completely ignore "legacy" types, which have been moved to HitObjects. + // even though these would mostly be ignored by the Add call, they will still be available in groups, + // which isn't what we want to be testing here. + if (point is SampleControlPoint) + continue; + + newControlPoints.Add(point.Time, point.DeepClone()); + } + + return newControlPoints; + } + } + [Test] public void TestEncodeMultiSegmentSliderWithFloatingPointError() { @@ -116,7 +174,7 @@ namespace osu.Game.Tests.Beatmaps.Formats } } - private Stream encodeToLegacy((IBeatmap beatmap, ISkin beatmapSkin) fullBeatmap) + private MemoryStream encodeToLegacy((IBeatmap beatmap, ISkin beatmapSkin) fullBeatmap) { var (beatmap, beatmapSkin) = fullBeatmap; var stream = new MemoryStream(); @@ -169,7 +227,7 @@ namespace osu.Game.Tests.Beatmaps.Formats protected override Track GetBeatmapTrack() => throw new NotImplementedException(); - protected override ISkin GetSkin() => throw new NotImplementedException(); + protected internal override ISkin GetSkin() => throw new NotImplementedException(); public override Stream GetStream(string storagePath) => throw new NotImplementedException(); } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index bcde899789..560e2ef894 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs @@ -149,5 +149,32 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[5]).LoopType); } } + + [Test] + public void TestDecodeLoopCount() + { + // all loop sequences in loop-count.osb have a total duration of 2000ms (fade in 0->1000ms, fade out 1000->2000ms). + const double loop_duration = 2000; + + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("loop-count.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); + + // stable ensures that any loop command executes at least once, even if the loop count specified in the .osb is zero or negative. + StoryboardSprite zeroTimes = background.Elements.OfType().Single(s => s.Path == "zero-times.png"); + Assert.That(zeroTimes.EndTime, Is.EqualTo(1000 + loop_duration)); + + StoryboardSprite oneTime = background.Elements.OfType().Single(s => s.Path == "one-time.png"); + Assert.That(oneTime.EndTime, Is.EqualTo(4000 + loop_duration)); + + StoryboardSprite manyTimes = background.Elements.OfType().Single(s => s.Path == "many-times.png"); + Assert.That(manyTimes.EndTime, Is.EqualTo(9000 + 40 * loop_duration)); + } + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs index e97c83e2c2..9ec2f37569 100644 --- a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs @@ -50,12 +50,13 @@ namespace osu.Game.Tests.Beatmaps.Formats var beatmap = decodeAsJson(normal); var beatmapInfo = beatmap.BeatmapInfo; Assert.AreEqual(0, beatmapInfo.AudioLeadIn); - Assert.AreEqual(false, beatmapInfo.Countdown); Assert.AreEqual(0.7f, beatmapInfo.StackLeniency); Assert.AreEqual(false, beatmapInfo.SpecialStyle); Assert.IsTrue(beatmapInfo.RulesetID == 0); Assert.AreEqual(false, beatmapInfo.LetterboxInBreaks); Assert.AreEqual(false, beatmapInfo.WidescreenStoryboard); + Assert.AreEqual(CountdownType.None, beatmapInfo.Countdown); + Assert.AreEqual(0, beatmapInfo.CountdownOffset); } [Test] @@ -83,7 +84,7 @@ namespace osu.Game.Tests.Beatmaps.Formats public void TestDecodeDifficulty() { var beatmap = decodeAsJson(normal); - var difficulty = beatmap.BeatmapInfo.BaseDifficulty; + var difficulty = beatmap.Difficulty; Assert.AreEqual(6.5f, difficulty.DrainRate); Assert.AreEqual(4, difficulty.CircleSize); Assert.AreEqual(8, difficulty.OverallDifficulty); @@ -101,7 +102,7 @@ namespace osu.Game.Tests.Beatmaps.Formats processor.PreProcess(); foreach (var o in converted.HitObjects) - o.ApplyDefaults(converted.ControlPointInfo, converted.BeatmapInfo.BaseDifficulty); + o.ApplyDefaults(converted.ControlPointInfo, converted.Difficulty); processor.PostProcess(); var beatmap = converted.Serialize().Deserialize(); diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index 5dc25d6643..4cc71717ff 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -86,7 +86,7 @@ namespace osu.Game.Tests.Beatmaps.IO var manager = osu.Dependencies.Get(); - BeatmapSetInfo importedSet; + ILive importedSet; using (var stream = File.OpenRead(tempPath)) { @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Beatmaps.IO Assert.IsTrue(File.Exists(tempPath), "Stream source file somehow went missing"); File.Delete(tempPath); - var imported = manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.ID); + var imported = manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.Value.ID); deleteBeatmapSet(imported, osu); } @@ -172,8 +172,8 @@ namespace osu.Game.Tests.Beatmaps.IO ensureLoaded(osu); // but contents doesn't, so existing should still be used. - Assert.IsTrue(imported.ID == importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + Assert.IsTrue(imported.ID == importedSecondTime.Value.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Value.Beatmaps.First().ID); } finally { @@ -226,8 +226,8 @@ namespace osu.Game.Tests.Beatmaps.IO 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); + Assert.IsTrue(imported.ID != importedSecondTime.Value.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Value.Beatmaps.First().ID); } finally { @@ -278,8 +278,8 @@ namespace osu.Game.Tests.Beatmaps.IO 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); + Assert.IsTrue(imported.ID != importedSecondTime.Value.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Value.Beatmaps.First().ID); } finally { @@ -329,8 +329,8 @@ namespace osu.Game.Tests.Beatmaps.IO 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); + Assert.IsTrue(imported.ID != importedSecondTime.Value.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Value.Beatmaps.First().ID); } finally { @@ -424,14 +424,14 @@ namespace osu.Game.Tests.Beatmaps.IO checkBeatmapCount(osu, 12); checkSingleReferencedFileCount(osu, 18); - var breakTemp = TestResources.GetTestBeatmapForImport(); + var brokenTempFilename = TestResources.GetTestBeatmapForImport(); MemoryStream brokenOsu = new MemoryStream(); - MemoryStream brokenOsz = new MemoryStream(await File.ReadAllBytesAsync(breakTemp)); + MemoryStream brokenOsz = new MemoryStream(await File.ReadAllBytesAsync(brokenTempFilename)); - File.Delete(breakTemp); + File.Delete(brokenTempFilename); - using (var outStream = File.Open(breakTemp, FileMode.CreateNew)) + using (var outStream = File.Open(brokenTempFilename, FileMode.CreateNew)) using (var zip = ZipArchive.Open(brokenOsz)) { zip.AddEntry("broken.osu", brokenOsu, false); @@ -441,7 +441,7 @@ namespace osu.Game.Tests.Beatmaps.IO // this will trigger purging of the existing beatmap (online set id match) but should rollback due to broken osu. try { - await manager.Import(new ImportTask(breakTemp)); + await manager.Import(new ImportTask(brokenTempFilename)); } catch { @@ -456,6 +456,8 @@ namespace osu.Game.Tests.Beatmaps.IO checkSingleReferencedFileCount(osu, 18); Assert.AreEqual(1, loggedExceptionCount); + + File.Delete(brokenTempFilename); } finally { @@ -568,8 +570,8 @@ namespace osu.Game.Tests.Beatmaps.IO var imported = await manager.Import(toImport); Assert.NotNull(imported); - Assert.AreEqual(null, imported.Beatmaps[0].OnlineBeatmapID); - Assert.AreEqual(null, imported.Beatmaps[1].OnlineBeatmapID); + Assert.AreEqual(null, imported.Value.Beatmaps[0].OnlineBeatmapID); + Assert.AreEqual(null, imported.Value.Beatmaps[1].OnlineBeatmapID); } finally { @@ -580,7 +582,6 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] [NonParallelizable] - [Ignore("Binding IPC on Appveyor isn't working (port in use). Need to figure out why")] public void TestImportOverIPC() { using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}-host", true)) @@ -705,7 +706,7 @@ namespace osu.Game.Tests.Beatmaps.IO ensureLoaded(osu); - Assert.IsFalse(imported.Files.Any(f => f.Filename.Contains("subfolder")), "Files contain common subfolder"); + Assert.IsFalse(imported.Value.Files.Any(f => f.Filename.Contains("subfolder")), "Files contain common subfolder"); } finally { @@ -758,8 +759,8 @@ namespace osu.Game.Tests.Beatmaps.IO ensureLoaded(osu); - Assert.IsFalse(imported.Files.Any(f => f.Filename.Contains("__MACOSX")), "Files contain resource fork folder, which should be ignored"); - Assert.IsFalse(imported.Files.Any(f => f.Filename.Contains("actual_data")), "Files contain common subfolder"); + Assert.IsFalse(imported.Value.Files.Any(f => f.Filename.Contains("__MACOSX")), "Files contain resource fork folder, which should be ignored"); + Assert.IsFalse(imported.Value.Files.Any(f => f.Filename.Contains("actual_data")), "Files contain common subfolder"); } finally { @@ -908,13 +909,13 @@ namespace osu.Game.Tests.Beatmaps.IO var manager = osu.Dependencies.Get(); - var importedSet = await manager.Import(new ImportTask(temp)); + var importedSet = await manager.Import(new ImportTask(temp)).ConfigureAwait(false); ensureLoaded(osu); waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); - return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.ID); + return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.Value.ID); } public static async Task LoadOszIntoOsu(OsuGameBase osu, string path = null, bool virtualTrack = false) @@ -923,13 +924,13 @@ namespace osu.Game.Tests.Beatmaps.IO var manager = osu.Dependencies.Get(); - var importedSet = await manager.Import(new ImportTask(temp)); + var importedSet = await manager.Import(new ImportTask(temp)).ConfigureAwait(false); ensureLoaded(osu); waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); - return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.ID); + return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.Value.ID); } private void deleteBeatmapSet(BeatmapSetInfo imported, OsuGameBase osu) @@ -944,13 +945,13 @@ namespace osu.Game.Tests.Beatmaps.IO Assert.IsTrue(manager.QueryBeatmapSets(_ => true).First().DeletePending); } - private static Task createScoreForBeatmap(OsuGameBase osu, BeatmapInfo beatmap) + private static Task createScoreForBeatmap(OsuGameBase osu, BeatmapInfo beatmapInfo) { return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo { OnlineScoreID = 2, - Beatmap = beatmap, - BeatmapInfoID = beatmap.ID + BeatmapInfo = beatmapInfo, + BeatmapInfoID = beatmapInfo.ID }, new ImportScoreTest.TestArchiveReader()); } 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/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs new file mode 100644 index 0000000000..dcfeea5db8 --- /dev/null +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Beatmaps +{ + [HeadlessTest] + public class TestSceneBeatmapDifficultyCache : OsuTestScene + { + public const double BASE_STARS = 5.55; + + private BeatmapSetInfo importedSet; + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + private TestBeatmapDifficultyCache difficultyCache; + + private IBindable starDifficultyBindable; + + [BackgroundDependencyLoader] + private void load(OsuGameBase osu) + { + importedSet = ImportBeatmapTest.LoadQuickOszIntoOsu(osu).Result; + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("setup difficulty cache", () => + { + SelectedMods.Value = Array.Empty(); + + Child = difficultyCache = new TestBeatmapDifficultyCache(); + + starDifficultyBindable = difficultyCache.GetBindableDifficulty(importedSet.Beatmaps.First()); + }); + + AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value?.Stars == BASE_STARS); + } + + [Test] + public void TestStarDifficultyChangesOnModSettings() + { + OsuModDoubleTime dt = null; + + AddStep("set computation function", () => difficultyCache.ComputeDifficulty = lookup => + { + var modRateAdjust = (ModRateAdjust)lookup.OrderedMods.SingleOrDefault(mod => mod is ModRateAdjust); + return new StarDifficulty(BASE_STARS + modRateAdjust?.SpeedChange.Value ?? 0, 0); + }); + + AddStep("change selected mod to DT", () => SelectedMods.Value = new[] { dt = new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } }); + AddUntilStep($"star difficulty -> {BASE_STARS + 1.5}", () => starDifficultyBindable.Value?.Stars == BASE_STARS + 1.5); + + AddStep("change DT speed to 1.25", () => dt.SpeedChange.Value = 1.25); + AddUntilStep($"star difficulty -> {BASE_STARS + 1.25}", () => starDifficultyBindable.Value?.Stars == BASE_STARS + 1.25); + + AddStep("change selected mod to NC", () => SelectedMods.Value = new[] { new OsuModNightcore { SpeedChange = { Value = 1.75 } } }); + AddUntilStep($"star difficulty -> {BASE_STARS + 1.75}", () => starDifficultyBindable.Value?.Stars == BASE_STARS + 1.75); + } + + [Test] + public void TestStarDifficultyAdjustHashCodeConflict() + { + OsuModDifficultyAdjust difficultyAdjust = null; + + AddStep("set computation function", () => difficultyCache.ComputeDifficulty = lookup => + { + var modDifficultyAdjust = (ModDifficultyAdjust)lookup.OrderedMods.SingleOrDefault(mod => mod is ModDifficultyAdjust); + return new StarDifficulty(BASE_STARS * (modDifficultyAdjust?.OverallDifficulty.Value ?? 1), 0); + }); + + AddStep("change selected mod to DA", () => SelectedMods.Value = new[] { difficultyAdjust = new OsuModDifficultyAdjust() }); + AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value?.Stars == BASE_STARS); + + AddStep("change DA difficulty to 0.5", () => difficultyAdjust.OverallDifficulty.Value = 0.5f); + AddUntilStep($"star difficulty -> {BASE_STARS * 0.5f}", () => starDifficultyBindable.Value?.Stars == BASE_STARS / 2); + + // hash code of 0 (the value) conflicts with the hash code of null (the initial/default value). + // it's important that the mod reference and its underlying bindable references stay the same to demonstrate this failure. + AddStep("change DA difficulty to 0", () => difficultyAdjust.OverallDifficulty.Value = 0); + AddUntilStep("star difficulty -> 0", () => starDifficultyBindable.Value?.Stars == 0); + } + + [Test] + public void TestKeyEqualsWithDifferentModInstances() + { + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); + + Assert.That(key1, Is.EqualTo(key2)); + Assert.That(key1.GetHashCode(), Is.EqualTo(key2.GetHashCode())); + } + + [Test] + public void TestKeyEqualsWithDifferentModOrder() + { + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHidden(), new OsuModHardRock() }); + + Assert.That(key1, Is.EqualTo(key2)); + Assert.That(key1.GetHashCode(), Is.EqualTo(key2.GetHashCode())); + } + + [Test] + public void TestKeyDoesntEqualWithDifferentModSettings() + { + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.9 } } }); + + Assert.That(key1, Is.Not.EqualTo(key2)); + Assert.That(key1.GetHashCode(), Is.Not.EqualTo(key2.GetHashCode())); + } + + [Test] + public void TestKeyEqualWithMatchingModSettings() + { + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); + + Assert.That(key1, Is.EqualTo(key2)); + Assert.That(key1.GetHashCode(), Is.EqualTo(key2.GetHashCode())); + } + + [TestCase(1.3, DifficultyRating.Easy)] + [TestCase(1.993, DifficultyRating.Easy)] + [TestCase(1.998, DifficultyRating.Normal)] + [TestCase(2.4, DifficultyRating.Normal)] + [TestCase(2.693, DifficultyRating.Normal)] + [TestCase(2.698, DifficultyRating.Hard)] + [TestCase(3.5, DifficultyRating.Hard)] + [TestCase(3.993, DifficultyRating.Hard)] + [TestCase(3.997, DifficultyRating.Insane)] + [TestCase(5.0, DifficultyRating.Insane)] + [TestCase(5.292, DifficultyRating.Insane)] + [TestCase(5.297, DifficultyRating.Expert)] + [TestCase(6.2, DifficultyRating.Expert)] + [TestCase(6.493, DifficultyRating.Expert)] + [TestCase(6.498, DifficultyRating.ExpertPlus)] + [TestCase(8.3, DifficultyRating.ExpertPlus)] + public void TestDifficultyRatingMapping(double starRating, DifficultyRating expectedBracket) + { + var actualBracket = BeatmapDifficultyCache.GetDifficultyRating(starRating); + + Assert.AreEqual(expectedBracket, actualBracket); + } + + private class TestBeatmapDifficultyCache : BeatmapDifficultyCache + { + public Func ComputeDifficulty { get; set; } + + protected override Task ComputeValueAsync(DifficultyCacheLookup lookup, CancellationToken token = default) + { + return Task.FromResult(ComputeDifficulty?.Invoke(lookup) ?? new StarDifficulty(BASE_STARS, 0)); + } + } + } +} diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index 2c2c4dc24e..af87fc17ad 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -509,5 +509,17 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(LinkAction.External, result.Action); Assert.AreEqual("/relative", result.Argument); } + + [TestCase("https://dev.ppy.sh/home/changelog", "")] + [TestCase("https://dev.ppy.sh/home/changelog/lazer/2021.1012", "lazer/2021.1012")] + public void TestChangelogLinks(string link, string expectedArg) + { + MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; + + LinkDetails result = MessageFormatter.GetLinkDetails(link); + + Assert.AreEqual(LinkAction.OpenChangelog, result.Action); + Assert.AreEqual(expectedArg, result.Argument); + } } } diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs index 0ec21a4c7b..5e22101e5c 100644 --- a/osu.Game.Tests/Chat/TestSceneChannelManager.cs +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.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 NUnit.Framework; using osu.Framework.Allocation; @@ -19,6 +20,7 @@ namespace osu.Game.Tests.Chat { private ChannelManager channelManager; private int currentMessageId; + private List sentMessages; [SetUp] public void Setup() => Schedule(() => @@ -34,6 +36,7 @@ namespace osu.Game.Tests.Chat AddStep("register request handling", () => { currentMessageId = 0; + sentMessages = new List(); ((DummyAPIAccess)API).HandleRequest = req => { @@ -44,16 +47,11 @@ namespace osu.Game.Tests.Chat 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 - }); + handlePostMessageRequest(postMessage); + return true; + case MarkChannelAsReadRequest markRead: + handleMarkChannelAsReadRequest(markRead); return true; } @@ -83,12 +81,65 @@ namespace osu.Game.Tests.Chat AddAssert("/np command received by channel 2", () => channel2.Messages.Last().Content.Contains("is listening to")); } + [Test] + public void TestMarkAsReadIgnoringLocalMessages() + { + Channel channel = null; + + AddStep("join channel and select it", () => + { + channelManager.JoinChannel(channel = createChannel(1, ChannelType.Public)); + channelManager.CurrentChannel.Value = channel; + }); + + AddStep("post message", () => channelManager.PostMessage("Something interesting")); + + AddStep("post /help command", () => channelManager.PostCommand("help", channel)); + AddStep("post /me command with no action", () => channelManager.PostCommand("me", channel)); + AddStep("post /join command with no channel", () => channelManager.PostCommand("join", channel)); + AddStep("post /join command with non-existent channel", () => channelManager.PostCommand("join i-dont-exist", channel)); + AddStep("post non-existent command", () => channelManager.PostCommand("non-existent-cmd arg", channel)); + + AddStep("mark channel as read", () => channelManager.MarkChannelAsRead(channel)); + AddAssert("channel's last read ID is set to the latest message", () => channel.LastReadId == sentMessages.Last().Id); + } + + private void handlePostMessageRequest(PostMessageRequest request) + { + var message = new Message(++currentMessageId) + { + IsAction = request.Message.IsAction, + ChannelId = request.Message.ChannelId, + Content = request.Message.Content, + Links = request.Message.Links, + Timestamp = request.Message.Timestamp, + Sender = request.Message.Sender + }; + + sentMessages.Add(message); + request.TriggerSuccess(message); + } + + private void handleMarkChannelAsReadRequest(MarkChannelAsReadRequest request) + { + // only accept messages that were sent through the API + if (sentMessages.Contains(request.Message)) + { + request.TriggerSuccess(); + } + else + { + request.TriggerFailure(new APIException("unknown message!", null)); + } + } + 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, + LastMessageId = 0, }; private class ChannelManagerContainer : CompositeDrawable diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs index 8f5ebf53bd..d87ac29d75 100644 --- a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Collections.IO @@ -127,7 +128,7 @@ namespace osu.Game.Tests.Collections.IO [Test] public async Task TestSaveAndReload() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + using (HeadlessGameHost host = new TestRunHeadlessGameHost("TestSaveAndReload", bypassCleanup: true)) { try { @@ -148,7 +149,7 @@ namespace osu.Game.Tests.Collections.IO } } - using (HeadlessGameHost host = new HeadlessGameHost("TestSaveAndReload")) + using (HeadlessGameHost host = new TestRunHeadlessGameHost("TestSaveAndReload")) { try { diff --git a/osu.Game.Tests/Database/FileStoreTests.cs b/osu.Game.Tests/Database/FileStoreTests.cs new file mode 100644 index 0000000000..861de5303d --- /dev/null +++ b/osu.Game.Tests/Database/FileStoreTests.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.Diagnostics; +using System.IO; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Logging; +using osu.Game.Models; +using osu.Game.Stores; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + public class FileStoreTests : RealmTest + { + [Test] + public void TestImportFile() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 }); + + realm.Write(() => files.Add(testData, realm)); + + Assert.True(files.Storage.Exists("0/05/054edec1d0211f624fed0cbca9d4f9400b0e491c43742af2c5b0abebf0c990d8")); + Assert.True(files.Storage.Exists(realm.All().First().StoragePath)); + }); + } + + [Test] + public void TestImportSameFileTwice() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 }); + + realm.Write(() => files.Add(testData, realm)); + realm.Write(() => files.Add(testData, realm)); + + Assert.AreEqual(1, realm.All().Count()); + }); + } + + [Test] + public void TestDontPurgeReferenced() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm)); + + var timer = new Stopwatch(); + timer.Start(); + + realm.Write(() => + { + // attach the file to an arbitrary beatmap + var beatmapSet = CreateBeatmapSet(CreateRuleset()); + + beatmapSet.Files.Add(new RealmNamedFileUsage(file, "arbitrary.resource")); + + realm.Add(beatmapSet); + }); + + Logger.Log($"Import complete at {timer.ElapsedMilliseconds}"); + + string path = file.StoragePath; + + Assert.True(realm.All().Any()); + Assert.True(files.Storage.Exists(path)); + + files.Cleanup(); + Logger.Log($"Cleanup complete at {timer.ElapsedMilliseconds}"); + + Assert.True(realm.All().Any()); + Assert.True(file.IsValid); + Assert.True(files.Storage.Exists(path)); + }); + } + + [Test] + public void TestPurgeUnreferenced() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm)); + + string path = file.StoragePath; + + Assert.True(realm.All().Any()); + Assert.True(files.Storage.Exists(path)); + + files.Cleanup(); + + Assert.False(realm.All().Any()); + Assert.False(file.IsValid); + Assert.False(files.Storage.Exists(path)); + }); + } + } +} diff --git a/osu.Game.Tests/Database/GeneralUsageTests.cs b/osu.Game.Tests/Database/GeneralUsageTests.cs new file mode 100644 index 0000000000..3e8b6091fd --- /dev/null +++ b/osu.Game.Tests/Database/GeneralUsageTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + [TestFixture] + public class GeneralUsageTests : RealmTest + { + /// + /// Just test the construction of a new database works. + /// + [Test] + public void TestConstructRealm() + { + RunTestWithRealm((realmFactory, _) => { realmFactory.CreateContext().Refresh(); }); + } + + [Test] + public void TestBlockOperations() + { + RunTestWithRealm((realmFactory, _) => + { + using (realmFactory.BlockAllOperations()) + { + } + }); + } + + [Test] + public void TestBlockOperationsWithContention() + { + RunTestWithRealm((realmFactory, _) => + { + ManualResetEventSlim stopThreadedUsage = new ManualResetEventSlim(); + ManualResetEventSlim hasThreadedUsage = new ManualResetEventSlim(); + + Task.Factory.StartNew(() => + { + using (realmFactory.CreateContext()) + { + hasThreadedUsage.Set(); + + stopThreadedUsage.Wait(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler); + + hasThreadedUsage.Wait(); + + Assert.Throws(() => + { + using (realmFactory.BlockAllOperations()) + { + } + }); + + stopThreadedUsage.Set(); + }); + } + } +} diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs new file mode 100644 index 0000000000..33aa1afb89 --- /dev/null +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -0,0 +1,213 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Models; +using Realms; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + public class RealmLiveTests : RealmTest + { + [Test] + public void TestLiveCastability() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive(); + + ILive iBeatmap = beatmap; + + Assert.AreEqual(0, iBeatmap.Value.Length); + }); + } + + [Test] + public void TestValueAccessWithOpenContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + Assert.DoesNotThrow(() => + { + using (realmFactory.CreateContext()) + { + var resolved = liveBeatmap.Value; + + Assert.IsTrue(resolved.Realm.IsClosed); + Assert.IsTrue(resolved.IsValid); + + // can access properties without a crash. + Assert.IsFalse(resolved.Hidden); + } + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestScopedReadWithoutContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + liveBeatmap.PerformRead(beatmap => + { + Assert.IsTrue(beatmap.IsValid); + Assert.IsFalse(beatmap.Hidden); + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestScopedWriteWithoutContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + liveBeatmap.PerformWrite(beatmap => { beatmap.Hidden = true; }); + liveBeatmap.PerformRead(beatmap => { Assert.IsTrue(beatmap.Hidden); }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestValueAccessWithoutOpenContextFails() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + Assert.Throws(() => + { + var unused = liveBeatmap.Value; + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestLiveAssumptions() + { + RunTestWithRealm((realmFactory, _) => + { + int changesTriggered = 0; + + using (var updateThreadContext = realmFactory.CreateContext()) + { + updateThreadContext.All().SubscribeForNotifications(gotChange); + RealmLive? liveBeatmap = null; + + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var ruleset = CreateRuleset(); + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + // add a second beatmap to ensure that a full refresh occurs below. + // not just a refresh from the resolved Live. + threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + // not yet seen by main context + Assert.AreEqual(0, updateThreadContext.All().Count()); + Assert.AreEqual(0, changesTriggered); + + var resolved = liveBeatmap.Value; + + // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point. + Assert.AreEqual(2, updateThreadContext.All().Count()); + Assert.AreEqual(1, changesTriggered); + + // even though the realm that this instance was resolved for was closed, it's still valid. + Assert.IsTrue(resolved.Realm.IsClosed); + Assert.IsTrue(resolved.IsValid); + + // can access properties without a crash. + Assert.IsFalse(resolved.Hidden); + + updateThreadContext.Write(r => + { + // can use with the main context. + r.Remove(resolved); + }); + } + + void gotChange(IRealmCollection sender, ChangeSet changes, Exception error) + { + changesTriggered++; + } + }); + } + } +} diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs new file mode 100644 index 0000000000..04c9f2577a --- /dev/null +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -0,0 +1,151 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Models; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + [TestFixture] + public abstract class RealmTest + { + private static readonly TemporaryNativeStorage storage; + + static RealmTest() + { + storage = new TemporaryNativeStorage("realm-test"); + storage.DeleteDirectory(string.Empty); + } + + protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) + { + host.Run(new RealmTestGame(() => + { + var testStorage = storage.GetStorageForDirectory(caller); + + using (var realmFactory = new RealmContextFactory(testStorage, caller)) + { + Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); + testAction(realmFactory, testStorage); + + realmFactory.Dispose(); + + Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); + realmFactory.Compact(); + Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}"); + } + })); + } + } + + protected void RunTestWithRealmAsync(Func testAction, [CallerMemberName] string caller = "") + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) + { + host.Run(new RealmTestGame(async () => + { + var testStorage = storage.GetStorageForDirectory(caller); + + using (var realmFactory = new RealmContextFactory(testStorage, caller)) + { + Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); + await testAction(realmFactory, testStorage); + + realmFactory.Dispose(); + + Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); + realmFactory.Compact(); + } + })); + } + } + + protected static RealmBeatmapSet CreateBeatmapSet(RealmRuleset ruleset) + { + RealmFile createRealmFile() => new RealmFile { Hash = Guid.NewGuid().ToString().ComputeSHA2Hash() }; + + var metadata = new RealmBeatmapMetadata + { + Title = "My Love", + Artist = "Kuba Oms" + }; + + var beatmapSet = new RealmBeatmapSet + { + Beatmaps = + { + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Easy", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Normal", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Hard", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Insane", } + }, + Files = + { + new RealmNamedFileUsage(createRealmFile(), "test [easy].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [normal].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [hard].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [insane].osu"), + } + }; + + for (int i = 0; i < 8; i++) + beatmapSet.Files.Add(new RealmNamedFileUsage(createRealmFile(), $"hitsound{i}.mp3")); + + foreach (var b in beatmapSet.Beatmaps) + b.BeatmapSet = beatmapSet; + + return beatmapSet; + } + + protected static RealmRuleset CreateRuleset() => + new RealmRuleset(0, "osu!", "osu", true); + + private class RealmTestGame : Framework.Game + { + public RealmTestGame(Func work) + { + // ReSharper disable once AsyncVoidLambda + Scheduler.Add(async () => + { + await work().ConfigureAwait(true); + Exit(); + }); + } + + public RealmTestGame(Action work) + { + Scheduler.Add(() => + { + work(); + Exit(); + }); + } + } + + private static long getFileSize(Storage testStorage, RealmContextFactory realmFactory) + { + try + { + using (var stream = testStorage.GetStream(realmFactory.Filename)) + return stream?.Length ?? 0; + } + catch + { + // windows runs may error due to file still being open. + return 0; + } + } + } +} diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs index cac331451b..f10b11733e 100644 --- a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs +++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs @@ -11,6 +11,7 @@ using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Input; using osu.Game.Input.Bindings; +using osu.Game.Rulesets; using Realms; namespace osu.Game.Tests.Database @@ -31,53 +32,63 @@ namespace osu.Game.Tests.Database storage = new NativeStorage(directory.FullName); - realmContextFactory = new RealmContextFactory(storage); + realmContextFactory = new RealmContextFactory(storage, "test"); keyBindingStore = new RealmKeyBindingStore(realmContextFactory); } [Test] public void TestDefaultsPopulationAndQuery() { - Assert.That(query().Count, Is.EqualTo(0)); + Assert.That(queryCount(), Is.EqualTo(0)); KeyBindingContainer testContainer = new TestKeyBindingContainer(); - keyBindingStore.Register(testContainer); + keyBindingStore.Register(testContainer, Enumerable.Empty()); - Assert.That(query().Count, Is.EqualTo(3)); + Assert.That(queryCount(), Is.EqualTo(3)); - Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Back).Count, Is.EqualTo(1)); - Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Select).Count, Is.EqualTo(2)); + Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(1)); + Assert.That(queryCount(GlobalAction.Select), Is.EqualTo(2)); } - private IQueryable query() => realmContextFactory.Context.All(); + private int queryCount(GlobalAction? match = null) + { + using (var realm = realmContextFactory.CreateContext()) + { + var results = 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); + keyBindingStore.Register(testContainer, Enumerable.Empty()); - var backBinding = query().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()) + using (var primaryRealm = realmContextFactory.CreateContext()) { - var binding = usage.Realm.ResolveReference(tsr); - binding.KeyCombination = new KeyCombination(InputKey.BackSpace); + var backBinding = primaryRealm.All().Single(k => k.ActionInt == (int)GlobalAction.Back); - usage.Commit(); + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape })); + + var tsr = ThreadSafeReference.Create(backBinding); + + using (var threadedContext = realmContextFactory.CreateContext()) + { + var binding = threadedContext.ResolveReference(tsr); + threadedContext.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace)); + } + + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); + + // check still correct after re-query. + backBinding = primaryRealm.All().Single(k => k.ActionInt == (int)GlobalAction.Back); + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); } - - Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); - - // check still correct after re-query. - backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back); - Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); } [TearDown] diff --git a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs index 41a8f72305..4ab6e5cef6 100644 --- a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks; using osu.Game.Rulesets.Objects; @@ -30,7 +31,7 @@ namespace osu.Game.Tests.Editing.Checks { check = new CheckMutedObjects(); - cpi = new ControlPointInfo(); + cpi = new LegacyControlPointInfo(); cpi.Add(0, new SampleControlPoint { SampleVolume = volume_regular }); cpi.Add(1000, new SampleControlPoint { SampleVolume = volume_low }); cpi.Add(2000, new SampleControlPoint { SampleVolume = volume_muted }); 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/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index bd34eaff63..a40a6dac4c 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Editing BeatDivisor.Value = 1; - composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1; + composer.EditorBeatmap.Difficulty.SliderMultiplier = 1; composer.EditorBeatmap.ControlPointInfo.Clear(); composer.EditorBeatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 1 }); @@ -64,7 +64,7 @@ namespace osu.Game.Tests.Editing [TestCase(2)] public void TestSliderMultiplier(float multiplier) { - AddStep($"set multiplier = {multiplier}", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = multiplier); + AddStep($"set multiplier = {multiplier}", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = multiplier); assertSnapDistance(100 * multiplier); } @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Editing assertDurationToDistance(500, 50); assertDurationToDistance(1000, 100); - AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); assertDurationToDistance(500, 100); assertDurationToDistance(1000, 200); @@ -118,7 +118,7 @@ namespace osu.Game.Tests.Editing assertDistanceToDuration(50, 500); assertDistanceToDuration(100, 1000); - AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); assertDistanceToDuration(100, 500); assertDistanceToDuration(200, 1000); @@ -143,7 +143,7 @@ namespace osu.Game.Tests.Editing assertSnappedDuration(200, 2000); assertSnappedDuration(250, 3000); - AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); assertSnappedDuration(0, 0); assertSnappedDuration(50, 0); @@ -175,7 +175,7 @@ namespace osu.Game.Tests.Editing assertSnappedDistance(200, 200); assertSnappedDistance(250, 200); - AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); assertSnappedDistance(50, 0); assertSnappedDistance(100, 0); diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs index 7264083338..296c5cef76 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs @@ -165,7 +165,7 @@ namespace osu.Game.Tests.Gameplay { var beatmap = new Beatmap { - BeatmapInfo = { BaseDifficulty = { DrainRate = 10 } }, + Difficulty = { DrainRate = 10 } }; beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = 0 }); @@ -200,7 +200,7 @@ namespace osu.Game.Tests.Gameplay { var beatmap = new Beatmap { - BeatmapInfo = { BaseDifficulty = { DrainRate = 10 } }, + Difficulty = { DrainRate = 10 } }; for (double time = startTime; time <= endTime; time += 100) diff --git a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs index 0ce71696bd..58f4c4c8db 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay @@ -121,6 +123,18 @@ namespace osu.Game.Tests.Gameplay AddAssert("Drawable lifetime is restored", () => dho.LifetimeStart == 666 && dho.LifetimeEnd == 999); } + [Test] + public void TestStateChangeBeforeLoadComplete() + { + TestDrawableHitObject dho = null; + AddStep("Add DHO and apply result", () => + { + Child = dho = new TestDrawableHitObject(new HitObject { StartTime = Time.Current }); + dho.MissForcefully(); + }); + AddAssert("DHO state is correct", () => dho.State.Value == ArmedState.Miss); + } + private class TestDrawableHitObject : DrawableHitObject { public const double INITIAL_LIFETIME_OFFSET = 100; @@ -141,6 +155,19 @@ namespace osu.Game.Tests.Gameplay if (SetLifetimeStartOnApply) LifetimeStart = LIFETIME_ON_APPLY; } + + public void MissForcefully() => ApplyResult(r => r.Type = HitResult.Miss); + + protected override void UpdateHitStateTransforms(ArmedState state) + { + if (state != ArmedState.Miss) + { + base.UpdateHitStateTransforms(state); + return; + } + + this.FadeOut(1000); + } } private class TestLifetimeEntry : HitObjectLifetimeEntry diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 76e5437305..34f70659e3 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -100,6 +100,14 @@ namespace osu.Game.Tests.Gameplay set => ComboIndexBindable.Value = value; } + public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + + public int ComboIndexWithOffsets + { + get => ComboIndexWithOffsetsBindable.Value; + set => ComboIndexWithOffsetsBindable.Value = value; + } + public Bindable LastInComboBindable { get; } = new Bindable(); public bool LastInCombo @@ -129,14 +137,8 @@ namespace osu.Game.Tests.Gameplay { 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/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index aed28f5f84..3bf6aaac7a 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -204,7 +204,7 @@ namespace osu.Game.Tests.Gameplay this.resources = resources; } - protected override ISkin GetSkin() => new TestSkin("test-sample", resources); + protected internal override ISkin GetSkin() => new TestSkin("test-sample", resources); } private class TestDrawableStoryboardSample : DrawableStoryboardSample diff --git a/osu.Game.Tests/ImportTest.cs b/osu.Game.Tests/ImportTest.cs index e888f51e98..dbeb453d4d 100644 --- a/osu.Game.Tests/ImportTest.cs +++ b/osu.Game.Tests/ImportTest.cs @@ -17,7 +17,7 @@ namespace osu.Game.Tests protected virtual TestOsuGameBase LoadOsuIntoHost(GameHost host, bool withBeatmap = false) { var osu = new TestOsuGameBase(withBeatmap); - Task.Run(() => host.Run(osu)) + Task.Factory.StartNew(() => host.Run(osu), TaskCreationOptions.LongRunning) .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/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs index 27cece42e8..b612899d79 100644 --- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs +++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs @@ -8,7 +8,7 @@ using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Input; -using osu.Game.Tests.Visual.Navigation; +using osu.Game.Tests.Visual; namespace osu.Game.Tests.Input { diff --git a/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs b/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs new file mode 100644 index 0000000000..9926acf772 --- /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.GetDisplayTitleRomanisable(); + + 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.GetDisplayTitleRomanisable(); + + 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/ModSettingsEqualityComparison.cs b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs index 7a5789f01a..ce6b3a68a5 100644 --- a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs +++ b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Game.Online.API; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Tests.Mods @@ -11,26 +12,42 @@ namespace osu.Game.Tests.Mods public class ModSettingsEqualityComparison { [Test] - public void Test() + public void TestAPIMod() { + var apiMod1 = new APIMod(new OsuModDoubleTime { SpeedChange = { Value = 1.25 } }); + var apiMod2 = new APIMod(new OsuModDoubleTime { SpeedChange = { Value = 1.26 } }); + var apiMod3 = new APIMod(new OsuModDoubleTime { SpeedChange = { Value = 1.26 } }); + + Assert.That(apiMod1, Is.Not.EqualTo(apiMod2)); + Assert.That(apiMod2, Is.EqualTo(apiMod2)); + Assert.That(apiMod2, Is.EqualTo(apiMod3)); + Assert.That(apiMod3, Is.EqualTo(apiMod2)); + } + + [Test] + public void TestMod() + { + var ruleset = new OsuRuleset(); + var mod1 = new OsuModDoubleTime { SpeedChange = { Value = 1.25 } }; var mod2 = new OsuModDoubleTime { SpeedChange = { Value = 1.26 } }; var mod3 = new OsuModDoubleTime { SpeedChange = { Value = 1.26 } }; - var apiMod1 = new APIMod(mod1); - var apiMod2 = new APIMod(mod2); - var apiMod3 = new APIMod(mod3); + + var doubleConvertedMod1 = new APIMod(mod1).ToMod(ruleset); + var doulbeConvertedMod2 = new APIMod(mod2).ToMod(ruleset); + var doulbeConvertedMod3 = new APIMod(mod3).ToMod(ruleset); Assert.That(mod1, Is.Not.EqualTo(mod2)); - Assert.That(apiMod1, Is.Not.EqualTo(apiMod2)); + Assert.That(doubleConvertedMod1, Is.Not.EqualTo(doulbeConvertedMod2)); Assert.That(mod2, Is.EqualTo(mod2)); - Assert.That(apiMod2, Is.EqualTo(apiMod2)); + Assert.That(doulbeConvertedMod2, Is.EqualTo(doulbeConvertedMod2)); Assert.That(mod2, Is.EqualTo(mod3)); - Assert.That(apiMod2, Is.EqualTo(apiMod3)); + Assert.That(doulbeConvertedMod2, Is.EqualTo(doulbeConvertedMod3)); Assert.That(mod3, Is.EqualTo(mod2)); - Assert.That(apiMod3, Is.EqualTo(apiMod2)); + Assert.That(doulbeConvertedMod3, Is.EqualTo(doulbeConvertedMod2)); } } } diff --git a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs index b27c257795..fabb016d5f 100644 --- a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs @@ -4,6 +4,7 @@ using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; namespace osu.Game.Tests.NonVisual { @@ -64,7 +65,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestAddRedundantSample() { - var cpi = new ControlPointInfo(); + var cpi = new LegacyControlPointInfo(); cpi.Add(0, new SampleControlPoint()); // is *not* redundant, special exception for first sample point cpi.Add(1000, new SampleControlPoint()); // is redundant @@ -142,7 +143,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestRemoveGroupAlsoRemovedControlPoints() { - var cpi = new ControlPointInfo(); + var cpi = new LegacyControlPointInfo(); var group = cpi.GroupAt(1000, true); @@ -248,13 +249,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 a540ad7247..5e14af5c27 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -6,10 +6,10 @@ using System.IO; using System.Linq; using System.Runtime.CompilerServices; using NUnit.Framework; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.IO; @@ -184,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))); } @@ -275,7 +278,7 @@ namespace osu.Game.Tests.NonVisual private static string getDefaultLocationFor(string testTypeName) { - string path = Path.Combine(RuntimeInfo.StartupDirectory, "headless", testTypeName); + string path = Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, testTypeName); if (Directory.Exists(path)) Directory.Delete(path, true); @@ -285,7 +288,7 @@ namespace osu.Game.Tests.NonVisual private string prepareCustomPath(string suffix = "") { - string path = Path.Combine(RuntimeInfo.StartupDirectory, $"custom-path{suffix}"); + string path = Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, $"custom-path{suffix}"); if (Directory.Exists(path)) Directory.Delete(path, true); @@ -305,6 +308,19 @@ namespace osu.Game.Tests.NonVisual InitialStorage = new DesktopStorage(defaultStorageLocation, this); InitialStorage.DeleteDirectory(string.Empty); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + try + { + // the storage may have changed from the initial location. + // this handles cleanup of the initial location. + InitialStorage.DeleteDirectory(string.Empty); + } + catch { } + } } } } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 8ff2743b6a..ed86daf8b6 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -239,7 +239,7 @@ namespace osu.Game.Tests.NonVisual.Filtering match = shouldMatch; } - public bool Matches(BeatmapInfo beatmap) => match; + public bool Matches(BeatmapInfo beatmapInfo) => match; public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) => false; } } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index a55bdd2df8..df42c70c87 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -256,7 +256,7 @@ namespace osu.Game.Tests.NonVisual.Filtering { public string CustomValue { get; set; } - public bool Matches(BeatmapInfo beatmap) => true; + public bool Matches(BeatmapInfo beatmapInfo) => true; public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) { 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/FormatUtilsTest.cs b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs index df095ddee3..d69822cdc5 100644 --- a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs +++ b/osu.Game.Tests/NonVisual/FormatUtilsTest.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.Globalization; using NUnit.Framework; using osu.Game.Utils; @@ -20,7 +19,7 @@ namespace osu.Game.Tests.NonVisual [TestCase(1, "100.00%")] public void TestAccuracyFormatting(double input, string expectedOutput) { - Assert.AreEqual(expectedOutput, input.FormatAccuracy(CultureInfo.InvariantCulture)); + Assert.AreEqual(expectedOutput, input.FormatAccuracy().ToString()); } } } diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index 0983b806e2..07ec86b0e7 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -24,6 +24,8 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddRepeatStep("add some users", () => Client.AddUser(new User { Id = id++ }), 5); checkPlayingUserCount(0); + AddAssert("playlist item is available", () => Client.CurrentMatchPlayingItem.Value != null); + changeState(3, MultiplayerUserState.WaitingForLoad); checkPlayingUserCount(3); @@ -41,6 +43,8 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddStep("leave room", () => Client.LeaveRoom()); checkPlayingUserCount(0); + + AddAssert("playlist item is null", () => Client.CurrentMatchPlayingItem.Value == null); } [Test] 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 e45b8f7dc5..785f31386d 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs @@ -40,10 +40,10 @@ namespace osu.Game.Tests.NonVisual.Skinning assertPlaybackPosition(0); AddStep("set start time to 1000", () => animationTimeReference.AnimationStartTime.Value = 1000); - assertPlaybackPosition(-1000); + assertPlaybackPosition(0); AddStep("set current time to 500", () => animationTimeReference.ManualClock.CurrentTime = 500); - assertPlaybackPosition(-500); + assertPlaybackPosition(0); } private void assertPlaybackPosition(double expectedPosition) diff --git a/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs b/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs new file mode 100644 index 0000000000..97d7880def --- /dev/null +++ b/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Game.Extensions; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class TimeDisplayExtensionTest + { + private static readonly object[][] editor_formatted_duration_tests = + { + new object[] { new TimeSpan(0, 0, 0, 0, 50), "00:00:050" }, + new object[] { new TimeSpan(0, 0, 0, 10, 50), "00:10:050" }, + new object[] { new TimeSpan(0, 0, 5, 10), "05:10:000" }, + new object[] { new TimeSpan(0, 1, 5, 10), "65:10:000" }, + }; + + [TestCaseSource(nameof(editor_formatted_duration_tests))] + public void TestEditorFormat(TimeSpan input, string expectedOutput) + { + Assert.AreEqual(expectedOutput, input.ToEditorFormattedString()); + } + + private static readonly object[][] formatted_duration_tests = + { + new object[] { new TimeSpan(0, 0, 10), "00:10" }, + new object[] { new TimeSpan(0, 5, 10), "05:10" }, + new object[] { new TimeSpan(1, 5, 10), "01:05:10" }, + new object[] { new TimeSpan(1, 1, 5, 10), "01:01:05:10" }, + }; + + [TestCaseSource(nameof(formatted_duration_tests))] + public void TestFormattedDuration(TimeSpan input, string expectedOutput) + { + Assert.AreEqual(expectedOutput, input.ToFormattedDuration().ToString()); + } + } +} diff --git a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs index aa29d76843..91c6b6c008 100644 --- a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs +++ b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs @@ -42,7 +42,7 @@ namespace osu.Game.Tests.Online AddAssert("response event fired", () => response != null); - AddAssert("request has response", () => request.Result == response); + AddAssert("request has response", () => request.Response == response); } [Test] diff --git a/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs b/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs new file mode 100644 index 0000000000..5491774e26 --- /dev/null +++ b/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; +using NUnit.Framework; +using osu.Game.Online; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; + +namespace osu.Game.Tests.Online +{ + [TestFixture] + public class TestMultiplayerMessagePackSerialization + { + [Test] + public void TestSerialiseRoom() + { + var room = new MultiplayerRoom(1) + { + MatchState = new TeamVersusRoomState() + }; + + var serialized = MessagePackSerializer.Serialize(room); + + var deserialized = MessagePackSerializer.Deserialize(serialized); + + Assert.IsTrue(deserialized.MatchState is TeamVersusRoomState); + } + + [Test] + public void TestSerialiseUserStateExpected() + { + var state = new TeamVersusUserState(); + + var serialized = MessagePackSerializer.Serialize(typeof(MatchUserState), state); + var deserialized = MessagePackSerializer.Deserialize(serialized); + + Assert.IsTrue(deserialized is TeamVersusUserState); + } + + [Test] + public void TestSerialiseUnionFailsWithSingalR() + { + var state = new TeamVersusUserState(); + + // SignalR serialises using the actual type, rather than a base specification. + var serialized = MessagePackSerializer.Serialize(typeof(TeamVersusUserState), state); + + // works with explicit type specified. + MessagePackSerializer.Deserialize(serialized); + + // fails with base (union) type. + Assert.Throws(() => MessagePackSerializer.Deserialize(serialized)); + } + + [Test] + public void TestSerialiseUnionSucceedsWithWorkaround() + { + var state = new TeamVersusUserState(); + + // SignalR serialises using the actual type, rather than a base specification. + var serialized = MessagePackSerializer.Serialize(typeof(TeamVersusUserState), state, SignalRUnionWorkaroundResolver.OPTIONS); + + // works with explicit type specified. + MessagePackSerializer.Deserialize(serialized); + + // works with custom resolver. + var deserialized = MessagePackSerializer.Deserialize(serialized, SignalRUnionWorkaroundResolver.OPTIONS); + Assert.IsTrue(deserialized is TeamVersusUserState); + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 42848ffc0c..558b874234 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -156,20 +156,49 @@ namespace osu.Game.Tests.Online { public TaskCompletionSource AllowImport = new TaskCompletionSource(); - public Task CurrentImportTask { get; private set; } + public Task> CurrentImportTask { get; private set; } - protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) - => new TestDownloadRequest(set); - - public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false) - : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap, performOnlineLookups) + public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null) + : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap) { } - public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host) { - await AllowImport.Task; - return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)); + return new TestBeatmapModelManager(this, storage, contextFactory, rulesets, api, host); + } + + protected override BeatmapModelDownloader CreateBeatmapModelDownloader(IBeatmapModelManager manager, IAPIProvider api, GameHost host) + { + return new TestBeatmapModelDownloader(manager, api, host); + } + + internal class TestBeatmapModelDownloader : BeatmapModelDownloader + { + public TestBeatmapModelDownloader(IBeatmapModelManager modelManager, IAPIProvider apiProvider, GameHost gameHost) + : base(modelManager, apiProvider, gameHost) + { + } + + protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) + => new TestDownloadRequest(set); + } + + internal class TestBeatmapModelManager : BeatmapModelManager + { + private readonly TestBeatmapManager testBeatmapManager; + + public TestBeatmapModelManager(TestBeatmapManager testBeatmapManager, Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost) + : base(storage, databaseContextFactory, rulesetStore, gameHost) + { + this.testBeatmapManager = testBeatmapManager; + } + + public override async Task> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + await testBeatmapManager.AllowImport.Task.ConfigureAwait(false); + return await (testBeatmapManager.CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(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/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index cef0532f9d..839366d98e 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -1,14 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.IO; using NUnit.Framework; using osu.Framework.IO.Stores; +using osu.Framework.Testing; namespace osu.Game.Tests.Resources { public static class TestResources { + public const double QUICK_BEATMAP_LENGTH = 10000; + + private static readonly TemporaryNativeStorage temp_storage = new TemporaryNativeStorage("TestResources"); + public static DllResourceStore GetStore() => new DllResourceStore(typeof(TestResources).Assembly); public static Stream OpenResource(string name) => GetStore().GetStream($"Resources/{name}"); @@ -23,7 +29,7 @@ namespace osu.Game.Tests.Resources /// A path to a copy of a beatmap archive (osz). Should be deleted after use. public static string GetQuickTestBeatmapForImport() { - var tempPath = Path.GetTempFileName() + ".osz"; + var tempPath = getTempFilename(); using (var stream = OpenResource("Archives/241526 Soleily - Renatus_virtual_quick.osz")) using (var newFile = File.Create(tempPath)) stream.CopyTo(newFile); @@ -39,7 +45,7 @@ namespace osu.Game.Tests.Resources /// A path to a copy of a beatmap archive (osz). Should be deleted after use. public static string GetTestBeatmapForImport(bool virtualTrack = false) { - var tempPath = Path.GetTempFileName() + ".osz"; + var tempPath = getTempFilename(); using (var stream = GetTestBeatmapStream(virtualTrack)) using (var newFile = File.Create(tempPath)) @@ -48,5 +54,7 @@ namespace osu.Game.Tests.Resources Assert.IsTrue(File.Exists(tempPath)); return tempPath; } + + private static string getTempFilename() => temp_storage.GetFullPath(Guid.NewGuid() + ".osz"); } } diff --git a/osu.Game.Tests/Resources/countdown-settings.osu b/osu.Game.Tests/Resources/countdown-settings.osu new file mode 100644 index 0000000000..333e48150d --- /dev/null +++ b/osu.Game.Tests/Resources/countdown-settings.osu @@ -0,0 +1,5 @@ +osu file format v14 + +[General] +Countdown: 2 +CountdownOffset: 3 diff --git a/osu.Game.Tests/Resources/loop-count.osb b/osu.Game.Tests/Resources/loop-count.osb new file mode 100644 index 0000000000..ec75e85ef1 --- /dev/null +++ b/osu.Game.Tests/Resources/loop-count.osb @@ -0,0 +1,15 @@ +osu file format v14 + +[Events] +Sprite,Background,TopCentre,"zero-times.png",320,240 + L,1000,0 + F,0,0,1000,0,1 + F,0,1000,2000,1,0 +Sprite,Background,TopCentre,"one-time.png",320,240 + L,4000,1 + F,0,0,1000,0,1 + F,0,1000,2000,1,0 +Sprite,Background,TopCentre,"many-times.png",320,240 + L,9000,40 + F,0,0,1000,0,1 + F,0,1000,2000,1,0 diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index 184a94912a..f0d9ece06f 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -36,9 +36,9 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Standardised, HitResult.Meh, 750_000)] [TestCase(ScoringMode.Standardised, HitResult.Ok, 800_000)] [TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)] - [TestCase(ScoringMode.Classic, HitResult.Meh, 50)] - [TestCase(ScoringMode.Classic, HitResult.Ok, 100)] - [TestCase(ScoringMode.Classic, HitResult.Great, 300)] + [TestCase(ScoringMode.Classic, HitResult.Meh, 41)] + [TestCase(ScoringMode.Classic, HitResult.Ok, 46)] + [TestCase(ScoringMode.Classic, HitResult.Great, 72)] public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore) { scoreProcessor.Mode.Value = scoringMode; @@ -85,19 +85,18 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 575_000)] // (3 * 30) / (4 * 30) * 300_000 + (0 / 4) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points) [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points) - [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] // (0 * 4 * 300) * (1 + 0 / 25) - [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 156)] // (((3 * 50) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) - [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 312)] // (((3 * 100) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) - [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 594)] // (((3 * 200) / (4 * 350)) * 4 * 300) * (1 + 1 / 25) - [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 936)] // (((3 * 300) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) - [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 936)] // (((3 * 350) / (4 * 350)) * 4 * 300) * (1 + 1 / 25) - [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] // (0 * 1 * 300) * (1 + 0 / 25) - [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 225)] // (((3 * 10) / (4 * 10)) * 1 * 300) * (1 + 0 / 25) - [TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (0 * 4 * 300) * (1 + 0 / 25) - [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 936)] // (((3 * 50) / (4 * 50)) * 4 * 300) * (1 + 1 / 25) - // TODO: The following two cases don't match expectations currently (a single hit is registered in acc portion when it shouldn't be). See https://github.com/ppy/osu/issues/12604. - [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 330)] // (1 * 1 * 300) * (1 + 0 / 25) + 3 * 10 (bonus points) - [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 450)] // (1 * 1 * 300) * (1 + 0 / 25) + 3 * 50 (bonus points) + [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] + [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 68)] + [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 81)] + [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 109)] + [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 149)] + [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 149)] + [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 9)] + [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 15)] + [TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] + [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 149)] + [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 18)] + [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 18)] public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore) { var minResult = new TestJudgement(hitResult).MinResult; @@ -129,8 +128,8 @@ namespace osu.Game.Tests.Rulesets.Scoring /// [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, 978_571)] // (3 * 10 + 100) / (4 * 10 + 100) * 300_000 + (1 / 1) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, 914_286)] // (3 * 0 + 100) / (4 * 10 + 100) * 300_000 + (1 / 1) * 700_000 - [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, 279)] // (((3 * 10 + 100) / (4 * 10 + 100)) * 1 * 300) * (1 + 0 / 25) - [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, 214)] // (((3 * 0 + 100) / (4 * 10 + 100)) * 1 * 300) * (1 + 0 / 25) + [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, 69)] // (((3 * 10 + 100) / (4 * 10 + 100)) * 1 * 300) * (1 + 0 / 25) + [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, 60)] // (((3 * 0 + 100) / (4 * 10 + 100)) * 1 * 300) * (1 + 0 / 25) public void TestSmallTicksAccuracy(ScoringMode scoringMode, HitResult hitResult, int expectedScore) { IEnumerable hitObjects = Enumerable 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/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index cd7d744f53..2cd02329b7 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -141,7 +141,7 @@ namespace osu.Game.Tests.Scores.IO var beatmapManager = osu.Dependencies.Get(); var scoreManager = osu.Dependencies.Get(); - beatmapManager.Delete(beatmapManager.QueryBeatmapSet(s => s.Beatmaps.Any(b => b.ID == imported.Beatmap.ID))); + beatmapManager.Delete(beatmapManager.QueryBeatmapSet(s => s.Beatmaps.Any(b => b.ID == imported.BeatmapInfo.ID))); Assert.That(scoreManager.Query(s => s.ID == imported.ID).DeletePending, Is.EqualTo(true)); var secondImport = await LoadScoreIntoOsu(osu, imported); @@ -181,7 +181,7 @@ namespace osu.Game.Tests.Scores.IO { var beatmapManager = osu.Dependencies.Get(); - score.Beatmap ??= beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); + score.BeatmapInfo ??= beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); score.Ruleset ??= new OsuRuleset().RulesetInfo; var scoreManager = osu.Dependencies.Get(); diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index 8124bd4199..b2600bb887 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -50,10 +50,10 @@ namespace osu.Game.Tests.Skins.IO var imported2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("test skin", "skinner"), "skin2.osk")); Assert.That(imported2.ID, Is.Not.EqualTo(imported.ID)); - Assert.That(osu.Dependencies.Get().GetAllUserSkins().Count, Is.EqualTo(1)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins(true).Count, Is.EqualTo(1)); // the first should be overwritten by the second import. - Assert.That(osu.Dependencies.Get().GetAllUserSkins().First().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins(true).First().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); } finally { @@ -76,10 +76,10 @@ namespace osu.Game.Tests.Skins.IO var imported2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk(string.Empty, string.Empty), "download.osk")); Assert.That(imported2.ID, Is.Not.EqualTo(imported.ID)); - Assert.That(osu.Dependencies.Get().GetAllUserSkins().Count, Is.EqualTo(2)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins(true).Count, Is.EqualTo(2)); - Assert.That(osu.Dependencies.Get().GetAllUserSkins().First().Files.First().FileInfoID, Is.EqualTo(imported.Files.First().FileInfoID)); - Assert.That(osu.Dependencies.Get().GetAllUserSkins().Last().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins(true).First().Files.First().FileInfoID, Is.EqualTo(imported.Files.First().FileInfoID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins(true).Last().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); } finally { @@ -101,10 +101,10 @@ namespace osu.Game.Tests.Skins.IO var imported2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("test skin v2.1", "skinner"), "skin2.osk")); Assert.That(imported2.ID, Is.Not.EqualTo(imported.ID)); - Assert.That(osu.Dependencies.Get().GetAllUserSkins().Count, Is.EqualTo(2)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins(true).Count, Is.EqualTo(2)); - Assert.That(osu.Dependencies.Get().GetAllUserSkins().First().Files.First().FileInfoID, Is.EqualTo(imported.Files.First().FileInfoID)); - Assert.That(osu.Dependencies.Get().GetAllUserSkins().Last().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins(true).First().Files.First().FileInfoID, Is.EqualTo(imported.Files.First().FileInfoID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins(true).Last().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); } finally { @@ -138,16 +138,42 @@ namespace osu.Game.Tests.Skins.IO } } - private MemoryStream createOsk(string name, string author) + [Test] + public async Task TestSameMetadataNameDifferentFolderName() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportSkinTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var imported = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("name 1", "author 1", false), "my custom skin 1")); + Assert.That(imported.Name, Is.EqualTo("name 1 [my custom skin 1]")); + Assert.That(imported.Creator, Is.EqualTo("author 1")); + + var imported2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("name 1", "author 1", false), "my custom skin 2")); + Assert.That(imported2.Name, Is.EqualTo("name 1 [my custom skin 2]")); + Assert.That(imported2.Creator, Is.EqualTo("author 1")); + + Assert.That(imported2.Hash, Is.Not.EqualTo(imported.Hash)); + } + finally + { + host.Exit(); + } + } + } + + private MemoryStream createOsk(string name, string author, bool makeUnique = true) { var zipStream = new MemoryStream(); using var zip = ZipArchive.Create(); - zip.AddEntry("skin.ini", generateSkinIni(name, author)); + zip.AddEntry("skin.ini", generateSkinIni(name, author, makeUnique)); zip.SaveTo(zipStream); return zipStream; } - private MemoryStream generateSkinIni(string name, string author) + private MemoryStream generateSkinIni(string name, string author, bool makeUnique = true) { var stream = new MemoryStream(); var writer = new StreamWriter(stream); @@ -155,8 +181,12 @@ namespace osu.Game.Tests.Skins.IO writer.WriteLine("[General]"); writer.WriteLine($"Name: {name}"); writer.WriteLine($"Author: {author}"); - writer.WriteLine(); - writer.WriteLine($"# unique {Guid.NewGuid()}"); + + if (makeUnique) + { + writer.WriteLine(); + writer.WriteLine($"# unique {Guid.NewGuid()}"); + } writer.Flush(); @@ -166,7 +196,7 @@ namespace osu.Game.Tests.Skins.IO private async Task loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null) { var skinManager = osu.Dependencies.Get(); - return await skinManager.Import(archive); + return (await skinManager.Import(archive)).Value; } } } diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs index eff430ac25..f03cda1489 100644 --- a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Skins private void load() { var imported = beatmaps.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-beatmap.osz"))).Result; - beatmap = beatmaps.GetWorkingBeatmap(imported.Beatmaps[0]); + beatmap = beatmaps.GetWorkingBeatmap(imported.Value.Beatmaps[0]); beatmap.LoadTrack(); } diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index c15d804a19..aadabec100 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -133,11 +133,12 @@ namespace osu.Game.Tests.Skins [Test] public void TestEmptyComboColoursNoFallback() { - AddStep("Add custom combo colours to user skin", () => userSource.Configuration.AddComboColours( + AddStep("Add custom combo colours to user skin", () => userSource.Configuration.CustomComboColours = new List + { new Color4(100, 150, 200, 255), new Color4(55, 110, 166, 255), new Color4(75, 125, 175, 255) - )); + }); AddStep("Disallow default colours fallback in beatmap skin", () => beatmapSource.Configuration.AllowDefaultComboColoursFallback = false); diff --git a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs new file mode 100644 index 0000000000..ffb3d41d18 --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +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.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 RefreshSources() + { + SetSources(sources); + } + } + + 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/Skins/TestSceneSkinResources.cs b/osu.Game.Tests/Skins/TestSceneSkinResources.cs index 107a96292f..10f1ab31df 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinResources.cs @@ -24,7 +24,7 @@ namespace osu.Game.Tests.Skins private void load() { var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).Result; - skin = skins.GetSkin(imported); + skin = skins.GetSkin(imported.Value); } [Test] diff --git a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs index fb50da32f3..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() { diff --git a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs new file mode 100644 index 0000000000..0107632f6e --- /dev/null +++ b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs @@ -0,0 +1,167 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using ManagedBass.Fx; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Audio.Effects; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Tests.Visual.Audio +{ + public class TestSceneAudioFilter : OsuTestScene + { + private OsuSpriteText lowPassText; + private AudioFilter lowPassFilter; + + private OsuSpriteText highPassText; + private AudioFilter highPassFilter; + + private Track track; + + private WaveformTestBeatmap beatmap; + + private OsuSliderBar lowPassSlider; + private OsuSliderBar highPassSlider; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + beatmap = new WaveformTestBeatmap(audio); + track = beatmap.LoadTrack(); + + Add(new FillFlowContainer + { + Children = new Drawable[] + { + lowPassFilter = new AudioFilter(audio.TrackMixer), + highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), + lowPassText = new OsuSpriteText + { + Padding = new MarginPadding(20), + Text = $"Low Pass: {lowPassFilter.Cutoff}hz", + Font = new FontUsage(size: 40) + }, + lowPassSlider = new OsuSliderBar + { + Width = 500, + Height = 50, + Padding = new MarginPadding(20), + Current = new BindableInt + { + MinValue = 0, + MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF, + } + }, + highPassText = new OsuSpriteText + { + Padding = new MarginPadding(20), + Text = $"High Pass: {highPassFilter.Cutoff}hz", + Font = new FontUsage(size: 40) + }, + highPassSlider = new OsuSliderBar + { + Width = 500, + Height = 50, + Padding = new MarginPadding(20), + Current = new BindableInt + { + MinValue = 0, + MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF, + } + } + } + }); + + lowPassSlider.Current.ValueChanged += e => + { + lowPassText.Text = $"Low Pass: {e.NewValue}hz"; + lowPassFilter.Cutoff = e.NewValue; + }; + + highPassSlider.Current.ValueChanged += e => + { + highPassText.Text = $"High Pass: {e.NewValue}hz"; + highPassFilter.Cutoff = e.NewValue; + }; + } + + #region Overrides of Drawable + + protected override void Update() + { + base.Update(); + highPassSlider.Current.Value = highPassFilter.Cutoff; + lowPassSlider.Current.Value = lowPassFilter.Cutoff; + } + + #endregion + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Play Track", () => track.Start()); + + AddStep("Reset filters", () => + { + lowPassFilter.Cutoff = AudioFilter.MAX_LOWPASS_CUTOFF; + highPassFilter.Cutoff = 0; + }); + + waitTrackPlay(); + } + + [Test] + public void TestLowPassSweep() + { + AddStep("Filter Sweep", () => + { + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() + .CutoffTo(0, 2000, Easing.OutCubic); + }); + + waitTrackPlay(); + + AddStep("Filter Sweep (reverse)", () => + { + lowPassFilter.CutoffTo(0).Then() + .CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic); + }); + + waitTrackPlay(); + AddStep("Stop track", () => track.Stop()); + } + + [Test] + public void TestHighPassSweep() + { + AddStep("Filter Sweep", () => + { + highPassFilter.CutoffTo(0).Then() + .CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic); + }); + + waitTrackPlay(); + + AddStep("Filter Sweep (reverse)", () => + { + highPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() + .CutoffTo(0, 2000, Easing.OutCubic); + }); + + waitTrackPlay(); + + AddStep("Stop track", () => track.Stop()); + } + + private void waitTrackPlay() => AddWaitStep("Let track play", 10); + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 1670d86545..693c66ccb0 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -232,7 +232,7 @@ namespace osu.Game.Tests.Visual.Background AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(new ScoreInfo { User = new User { Username = "osu!" }, - Beatmap = new TestBeatmap(Ruleset.Value).BeatmapInfo, + BeatmapInfo = new TestBeatmap(Ruleset.Value).BeatmapInfo, Ruleset = Ruleset.Value, }))); @@ -286,7 +286,7 @@ namespace osu.Game.Tests.Visual.Background private void setupUserSettings() { AddUntilStep("Song select is current", () => songSelect.IsCurrentScreen()); - AddUntilStep("Song select has selection", () => songSelect.Carousel?.SelectedBeatmap != null); + AddUntilStep("Song select has selection", () => songSelect.Carousel?.SelectedBeatmapInfo != null); AddStep("Set default user settings", () => { SelectedMods.Value = SelectedMods.Value.Concat(new[] { new OsuModNoFail() }).ToArray(); diff --git a/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs b/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs new file mode 100644 index 0000000000..c345320e28 --- /dev/null +++ b/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Tests.Visual.Colours +{ + public class TestSceneStarDifficultyColours : OsuTestScene + { + [Resolved] + private OsuColour colours { get; set; } + + [Test] + public void TestColours() + { + AddStep("load colour displays", () => + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5f), + ChildrenEnumerable = Enumerable.Range(0, 10).Select(i => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10f), + ChildrenEnumerable = Enumerable.Range(0, 10).Select(j => + { + var colour = colours.ForStarDifficulty(1f * i + 0.1f * j); + + return new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new Drawable[] + { + new CircularContainer + { + Masking = true, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(75f, 25f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = OsuColour.ForegroundTextColourFor(colour), + Text = colour.ToHex(), + }, + } + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = $"*{(1f * i + 0.1f * j):0.00}", + } + } + }; + }) + }) + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs b/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs index 2236f85b92..cc8503589d 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Logging; +using osu.Framework.Testing; using osu.Game.Graphics.Sprites; using osu.Game.Online; using osuTK; @@ -15,6 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Components { + [HeadlessTest] public class TestScenePollingComponent : OsuTestScene { private Container pollBox; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs new file mode 100644 index 0000000000..5d8a6dabd7 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneBlueprintOrdering : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private EditorBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + [Test] + public void TestSelectedObjectHasPriorityWhenOverlapping() + { + var firstSlider = new Slider + { + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2()), + new PathControlPoint(new Vector2(150, -50)), + new PathControlPoint(new Vector2(300, 0)) + }), + Position = new Vector2(0, 100) + }; + var secondSlider = new Slider + { + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2()), + new PathControlPoint(new Vector2(-50, 50)), + new PathControlPoint(new Vector2(-100, 100)) + }), + Position = new Vector2(200, 0) + }; + + AddStep("add overlapping sliders", () => + { + EditorBeatmap.Add(firstSlider); + EditorBeatmap.Add(secondSlider); + }); + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(firstSlider)); + + AddStep("move mouse to common point", () => + { + var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; + InputManager.MoveMouseTo(pos); + }); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + + AddAssert("selection is unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == firstSlider); + } + + [Test] + public void TestOverlappingObjectsWithSameStartTime() + { + AddStep("add overlapping circles", () => + { + EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2)); + EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2 + new Vector2(-10, -20))); + EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2 + new Vector2(10, -20))); + }); + + AddStep("click at centre of playfield", () => + { + var hitObjectContainer = Editor.ChildrenOfType().Single(); + var centre = hitObjectContainer.ToScreenSpace(OsuPlayfield.BASE_SIZE / 2); + InputManager.MoveMouseTo(centre); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("frontmost object selected", () => + { + var hasCombo = Editor.ChildrenOfType().Single(b => b.IsSelected).Item as IHasComboInformation; + return hasCombo?.IndexInCurrentCombo == 0; + }); + } + + [Test] + public void TestPlacementOfConcurrentObjectWithDuration() + { + AddStep("seek to timing point", () => EditorClock.Seek(2170)); + AddStep("add hit circle", () => EditorBeatmap.Add(createHitCircle(2170, Vector2.Zero))); + + AddStep("choose spinner placement tool", () => + { + InputManager.Key(Key.Number4); + var hitObjectContainer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(hitObjectContainer.ScreenSpaceDrawQuad.Centre); + }); + + AddStep("begin placing spinner", () => + { + InputManager.Click(MouseButton.Left); + }); + AddStep("end placing spinner", () => + { + EditorClock.Seek(2500); + InputManager.Click(MouseButton.Right); + }); + + AddAssert("two timeline blueprints present", () => Editor.ChildrenOfType().Count() == 2); + } + + private HitCircle createHitCircle(double startTime, Vector2 position) => new HitCircle + { + StartTime = startTime, + Position = position, + }; + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs deleted file mode 100644 index 976bf93c15..0000000000 --- a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using NUnit.Framework; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Screens.Edit.Compose.Components; -using osu.Game.Tests.Beatmaps; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.Editing -{ - public class TestSceneBlueprintSelection : EditorTestScene - { - protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); - - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); - - private EditorBlueprintContainer blueprintContainer - => Editor.ChildrenOfType().First(); - - [Test] - public void TestSelectedObjectHasPriorityWhenOverlapping() - { - var firstSlider = new Slider - { - Path = new SliderPath(new[] - { - new PathControlPoint(new Vector2()), - new PathControlPoint(new Vector2(150, -50)), - new PathControlPoint(new Vector2(300, 0)) - }), - Position = new Vector2(0, 100) - }; - var secondSlider = new Slider - { - Path = new SliderPath(new[] - { - new PathControlPoint(new Vector2()), - new PathControlPoint(new Vector2(-50, 50)), - new PathControlPoint(new Vector2(-100, 100)) - }), - Position = new Vector2(200, 0) - }; - - AddStep("add overlapping sliders", () => - { - EditorBeatmap.Add(firstSlider); - EditorBeatmap.Add(secondSlider); - }); - AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(firstSlider)); - - AddStep("move mouse to common point", () => - { - var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; - InputManager.MoveMouseTo(pos); - }); - AddStep("right click", () => InputManager.Click(MouseButton.Right)); - - AddAssert("selection is unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == firstSlider); - } - } -} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs index 6f5655006e..4813598c9d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; @@ -30,7 +31,10 @@ namespace osu.Game.Tests.Visual.Editing { Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); - Child = new ComposeScreen(); + Child = new ComposeScreen + { + State = { Value = Visibility.Visible }, + }; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs index d5cfeb1878..87dbb90138 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -35,6 +35,8 @@ namespace osu.Game.Tests.Visual.Editing CanRotate = true, CanScaleX = true, CanScaleY = true, + CanFlipX = true, + CanFlipY = true, OnRotation = handleRotation, OnScale = handleScale diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs similarity index 98% rename from osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs rename to osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs index 4c4a87972f..a2a7b72283 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs @@ -20,14 +20,14 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorSelection : EditorTestScene + public class TestSceneComposerSelection : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); - private EditorBlueprintContainer blueprintContainer - => Editor.ChildrenOfType().First(); + private ComposeBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); private void moveMouseToObject(Func targetFunc) { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs new file mode 100644 index 0000000000..00f2979691 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Setup; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneDesignSection : OsuManualInputManagerTestScene + { + private TestDesignSection designSection; + private EditorBeatmap editorBeatmap { get; set; } + + [SetUpSteps] + public void SetUp() + { + AddStep("create blank beatmap", () => editorBeatmap = new EditorBeatmap(new Beatmap())); + AddStep("create section", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(EditorBeatmap), editorBeatmap) + }, + Child = designSection = new TestDesignSection() + }); + } + + [Test] + public void TestCountdownOff() + { + AddStep("turn countdown off", () => designSection.EnableCountdown.Current.Value = false); + + AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.None); + AddUntilStep("other controls hidden", () => !designSection.CountdownSettings.IsPresent); + } + + [Test] + public void TestCountdownOn() + { + AddStep("turn countdown on", () => designSection.EnableCountdown.Current.Value = true); + + AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.Normal); + AddUntilStep("other controls shown", () => designSection.CountdownSettings.IsPresent); + + AddStep("change countdown speed", () => designSection.CountdownSpeed.Current.Value = CountdownType.DoubleSpeed); + + AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.DoubleSpeed); + AddUntilStep("other controls still shown", () => designSection.CountdownSettings.IsPresent); + } + + [Test] + public void TestCountdownOffset() + { + AddStep("turn countdown on", () => designSection.EnableCountdown.Current.Value = true); + + AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.Normal); + + checkOffsetAfter("1", 1); + checkOffsetAfter(string.Empty, 0); + checkOffsetAfter("123", 123); + checkOffsetAfter("0", 0); + } + + private void checkOffsetAfter(string userInput, int expectedFinalValue) + { + AddStep("click text box", () => + { + var textBox = designSection.CountdownOffset.ChildrenOfType().Single(); + InputManager.MoveMouseTo(textBox); + InputManager.Click(MouseButton.Left); + }); + AddStep("set offset text", () => designSection.CountdownOffset.Current.Value = userInput); + AddStep("commit text", () => InputManager.Key(Key.Enter)); + + AddAssert($"displayed value is {expectedFinalValue}", () => designSection.CountdownOffset.Current.Value == expectedFinalValue.ToString(CultureInfo.InvariantCulture)); + AddAssert($"beatmap value is {expectedFinalValue}", () => editorBeatmap.BeatmapInfo.CountdownOffset == expectedFinalValue); + } + + private class TestDesignSection : DesignSection + { + public new LabelledSwitchButton EnableCountdown => base.EnableCountdown; + + public new FillFlowContainer CountdownSettings => base.CountdownSettings; + public new LabelledEnumDropdown CountdownSpeed => base.CountdownSpeed; + public new LabelledNumberBox CountdownOffset => base.CountdownOffset; + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs new file mode 100644 index 0000000000..c81a1abfbc --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs @@ -0,0 +1,177 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Beatmaps.IO; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneDifficultySwitching : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override bool IsolateSavingFromDatabase => false; + + [Resolved] + private OsuGameBase game { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + private BeatmapSetInfo importedBeatmapSet; + + public override void SetUpSteps() + { + AddStep("import test beatmap", () => importedBeatmapSet = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result); + base.SetUpSteps(); + } + + protected override void LoadEditor() + { + Beatmap.Value = beatmaps.GetWorkingBeatmap(importedBeatmapSet.Beatmaps.First()); + base.LoadEditor(); + } + + [Test] + public void TestBasicSwitch() + { + BeatmapInfo targetDifficulty = null; + + AddStep("set target difficulty", () => targetDifficulty = importedBeatmapSet.Beatmaps.Last(beatmap => !beatmap.Equals(Beatmap.Value.BeatmapInfo))); + switchToDifficulty(() => targetDifficulty); + confirmEditingBeatmap(() => targetDifficulty); + + AddStep("exit editor", () => Stack.Exit()); + // ensure editor loader didn't resume. + AddAssert("stack empty", () => Stack.CurrentScreen == null); + } + + [Test] + public void TestClockPositionPreservedBetweenSwitches() + { + BeatmapInfo targetDifficulty = null; + AddStep("seek editor to 00:05:00", () => EditorClock.Seek(5000)); + + AddStep("set target difficulty", () => targetDifficulty = importedBeatmapSet.Beatmaps.Last(beatmap => !beatmap.Equals(Beatmap.Value.BeatmapInfo))); + switchToDifficulty(() => targetDifficulty); + confirmEditingBeatmap(() => targetDifficulty); + AddAssert("editor clock at 00:05:00", () => EditorClock.CurrentTime == 5000); + + AddStep("exit editor", () => Stack.Exit()); + // ensure editor loader didn't resume. + AddAssert("stack empty", () => Stack.CurrentScreen == null); + } + + [Test] + public void TestClipboardPreservedAfterSwitch([Values] bool sameRuleset) + { + BeatmapInfo targetDifficulty = null; + + AddStep("select first object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.First())); + AddStep("copy object", () => Editor.Copy()); + + AddStep("set target difficulty", () => + { + targetDifficulty = sameRuleset + ? importedBeatmapSet.Beatmaps.Last(beatmap => !beatmap.Equals(Beatmap.Value.BeatmapInfo) && beatmap.RulesetID == Beatmap.Value.BeatmapInfo.RulesetID) + : importedBeatmapSet.Beatmaps.Last(beatmap => !beatmap.Equals(Beatmap.Value.BeatmapInfo) && beatmap.RulesetID != Beatmap.Value.BeatmapInfo.RulesetID); + }); + switchToDifficulty(() => targetDifficulty); + confirmEditingBeatmap(() => targetDifficulty); + + AddAssert("no objects selected", () => !EditorBeatmap.SelectedHitObjects.Any()); + AddStep("paste object", () => Editor.Paste()); + + if (sameRuleset) + AddAssert("object was pasted", () => EditorBeatmap.SelectedHitObjects.Any()); + else + AddAssert("object was not pasted", () => !EditorBeatmap.SelectedHitObjects.Any()); + + AddStep("exit editor", () => Stack.Exit()); + + if (sameRuleset) + { + AddUntilStep("prompt for save dialog shown", () => DialogOverlay.CurrentDialog is PromptForSaveDialog); + AddStep("discard changes", () => ((PromptForSaveDialog)DialogOverlay.CurrentDialog).PerformOkAction()); + } + + // ensure editor loader didn't resume. + AddAssert("stack empty", () => Stack.CurrentScreen == null); + } + + [Test] + public void TestPreventSwitchDueToUnsavedChanges() + { + BeatmapInfo targetDifficulty = null; + PromptForSaveDialog saveDialog = null; + + AddStep("remove first hitobject", () => EditorBeatmap.RemoveAt(0)); + + AddStep("set target difficulty", () => targetDifficulty = importedBeatmapSet.Beatmaps.Last(beatmap => !beatmap.Equals(Beatmap.Value.BeatmapInfo))); + switchToDifficulty(() => targetDifficulty); + + AddUntilStep("prompt for save dialog shown", () => + { + saveDialog = this.ChildrenOfType().Single(); + return saveDialog != null; + }); + AddStep("continue editing", () => + { + var continueButton = saveDialog.ChildrenOfType().Last(); + continueButton.TriggerClick(); + }); + + confirmEditingBeatmap(() => importedBeatmapSet.Beatmaps.First()); + + AddRepeatStep("exit editor forcefully", () => Stack.Exit(), 2); + // ensure editor loader didn't resume. + AddAssert("stack empty", () => Stack.CurrentScreen == null); + } + + [Test] + public void TestAllowSwitchAfterDiscardingUnsavedChanges() + { + BeatmapInfo targetDifficulty = null; + PromptForSaveDialog saveDialog = null; + + AddStep("remove first hitobject", () => EditorBeatmap.RemoveAt(0)); + + AddStep("set target difficulty", () => targetDifficulty = importedBeatmapSet.Beatmaps.Last(beatmap => !beatmap.Equals(Beatmap.Value.BeatmapInfo))); + switchToDifficulty(() => targetDifficulty); + + AddUntilStep("prompt for save dialog shown", () => + { + saveDialog = this.ChildrenOfType().Single(); + return saveDialog != null; + }); + AddStep("discard changes", () => + { + var continueButton = saveDialog.ChildrenOfType().Single(); + continueButton.TriggerClick(); + }); + + confirmEditingBeatmap(() => targetDifficulty); + + AddStep("exit editor forcefully", () => Stack.Exit()); + // ensure editor loader didn't resume. + AddAssert("stack empty", () => Stack.CurrentScreen == null); + } + + private void switchToDifficulty(Func difficulty) => AddStep("switch to difficulty", () => Editor.SwitchToDifficulty(difficulty.Invoke())); + + private void confirmEditingBeatmap(Func targetDifficulty) + { + AddUntilStep("current beatmap is correct", () => Beatmap.Value.BeatmapInfo.Equals(targetDifficulty.Invoke())); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen == Editor && Editor?.IsLoaded == true); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index b6ae91844a..440d66ff9f 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -11,6 +11,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; using osu.Game.Tests.Resources; using SharpCompress.Archives; @@ -55,6 +56,9 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestExitWithoutSave() { + EditorBeatmap editorBeatmap = null; + + AddStep("store editor beatmap", () => editorBeatmap = EditorBeatmap); AddStep("exit without save", () => { Editor.Exit(); @@ -62,7 +66,7 @@ namespace osu.Game.Tests.Visual.Editing }); AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen()); - AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == true); + AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == true); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs index 0b1617b6a6..0abf0c47f8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs @@ -78,6 +78,24 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); } + [Test] + public void TestClampWhenSeekOutsideBeatmapBounds() + { + AddStep("stop clock", Clock.Stop); + + AddStep("seek before start time", () => Clock.Seek(-1000)); + AddAssert("time is clamped to 0", () => Clock.CurrentTime == 0); + + AddStep("seek beyond track length", () => Clock.Seek(Clock.TrackLength + 1000)); + AddAssert("time is clamped to track length", () => Clock.CurrentTime == Clock.TrackLength); + + AddStep("seek smoothly before start time", () => Clock.SeekSmoothlyTo(-1000)); + AddAssert("time is clamped to 0", () => Clock.CurrentTime == 0); + + AddStep("seek smoothly beyond track length", () => Clock.SeekSmoothlyTo(Clock.TrackLength + 1000)); + AddAssert("time is clamped to track length", () => Clock.CurrentTime == Clock.TrackLength); + } + protected override void Dispose(bool isDisposing) { Beatmap.Disabled = false; 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/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs new file mode 100644 index 0000000000..f0aa3e2350 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Input; +using osu.Framework.Testing; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Select; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorSaving : OsuGameTestScene + { + private Editor editor => Game.ChildrenOfType().FirstOrDefault(); + + private EditorBeatmap editorBeatmap => (EditorBeatmap)editor.Dependencies.Get(typeof(EditorBeatmap)); + + /// + /// Tests the general expected flow of creating a new beatmap, saving it, then loading it back from song select. + /// + [Test] + public void TestNewBeatmapSaveThenLoad() + { + AddStep("set default beatmap", () => Game.Beatmap.SetDefault()); + + PushAndConfirm(() => new EditorLoader()); + + AddUntilStep("wait for editor load", () => editor != null); + + AddStep("Set overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty = 7); + + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + + AddStep("Enter compose mode", () => InputManager.Key(Key.F1)); + AddUntilStep("Wait for compose mode load", () => editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + + AddStep("Change to placement mode", () => InputManager.Key(Key.Number2)); + AddStep("Move to playfield", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre)); + AddStep("Place single hitcircle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1); + + AddStep("Save", () => InputManager.Keys(PlatformAction.Save)); + + AddStep("Exit", () => InputManager.Key(Key.Escape)); + + AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + + PushAndConfirm(() => new PlaySongSelect()); + + AddUntilStep("Wait for beatmap selected", () => !Game.Beatmap.IsDefault); + AddStep("Open options", () => InputManager.Key(Key.F3)); + AddStep("Enter editor", () => InputManager.Key(Key.Number5)); + + AddUntilStep("Wait for editor load", () => editor != null); + AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1); + AddAssert("Beatmap has correct overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty == 7); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs new file mode 100644 index 0000000000..98d8a41674 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components.Menus; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorScreenModes : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestSwitchScreensInstantaneously() + { + AddStep("switch between all screens at once", () => + { + foreach (var screen in Enum.GetValues(typeof(EditorScreenMode)).Cast()) + Editor.ChildrenOfType().Single().Mode.Value = screen; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs index 96ce418851..ff741a8ed5 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tests.Visual.Editing beatmap.BeatmapInfo.BeatDivisor = 1; - beatmap.ControlPointInfo = new ControlPointInfo(); + beatmap.ControlPointInfo.Clear(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }); beatmap.ControlPointInfo.Add(2000, new TimingControlPoint { BeatLength = 500 }); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index 7ca24346aa..c758bd1707 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").TriggerClick()); + 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/TestSceneRectangularPositionSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..85a98eca47 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneRectangularPositionSnapGrid : OsuManualInputManagerTestScene + { + private Container content; + protected override Container Content => content; + + [BackgroundDependencyLoader] + private void load() + { + base.Content.AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Gray + }, + content = new Container + { + RelativeSizeAxes = Axes.Both + } + }); + } + + private static readonly object[][] test_cases = + { + new object[] { new Vector2(0, 0), new Vector2(10, 10) }, + new object[] { new Vector2(240, 180), new Vector2(10, 15) }, + new object[] { new Vector2(160, 120), new Vector2(30, 20) }, + new object[] { new Vector2(480, 360), new Vector2(100, 100) }, + }; + + [TestCaseSource(nameof(test_cases))] + public void TestRectangularGrid(Vector2 position, Vector2 spacing) + { + RectangularPositionSnapGrid grid = null; + + AddStep("create grid", () => Child = grid = new RectangularPositionSnapGrid(position) + { + RelativeSizeAxes = Axes.Both, + Spacing = spacing + }); + + AddStep("add snapping cursor", () => Add(new SnappingCursorContainer + { + RelativeSizeAxes = Axes.Both, + GetSnapPosition = pos => grid.GetSnappedPosition(grid.ToLocalSpace(pos)) + })); + } + + private class SnappingCursorContainer : CompositeDrawable + { + public Func GetSnapPosition; + + private readonly Drawable cursor; + + public SnappingCursorContainer() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = cursor = new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(50), + Colour = Color4.Red + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updatePosition(GetContainingInputManager().CurrentState.Mouse.Position); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + base.OnMouseMove(e); + + updatePosition(e.ScreenSpaceMousePosition); + return true; + } + + private void updatePosition(Vector2 screenSpacePosition) + { + cursor.Position = GetSnapPosition.Invoke(screenSpacePosition); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs index 62e12158ab..c3c803ff23 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs @@ -3,8 +3,14 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Taiko; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; @@ -22,11 +28,31 @@ namespace osu.Game.Tests.Visual.Editing editorBeatmap = new EditorBeatmap(new OsuBeatmap()); } - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestOsu() => runForRuleset(new OsuRuleset().RulesetInfo); + + [Test] + public void TestTaiko() => runForRuleset(new TaikoRuleset().RulesetInfo); + + [Test] + public void TestCatch() => runForRuleset(new CatchRuleset().RulesetInfo); + + [Test] + public void TestMania() => runForRuleset(new ManiaRuleset().RulesetInfo); + + private void runForRuleset(RulesetInfo rulesetInfo) { - Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); - Child = new SetupScreen(); + AddStep("create screen", () => + { + editorBeatmap.BeatmapInfo.Ruleset = rulesetInfo; + + Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); + + Child = new SetupScreen + { + State = { Value = Visibility.Visible }, + }; + }); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs new file mode 100644 index 0000000000..2544b6c2a1 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs @@ -0,0 +1,266 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneTimelineSelection : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private TimelineBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + private void moveMouseToObject(Func targetFunc) + { + AddStep("move mouse to object", () => + { + var pos = blueprintContainer.SelectionBlueprints + .First(s => s.Item == targetFunc()) + .ChildrenOfType() + .First().ScreenSpaceDrawQuad.Centre; + + InputManager.MoveMouseTo(pos); + }); + } + + [Test] + public void TestNudgeSelection() + { + HitCircle[] addedObjects = null; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + })); + + AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + + AddStep("nudge forwards", () => InputManager.Key(Key.K)); + AddAssert("objects moved forwards in time", () => addedObjects[0].StartTime > 100); + + AddStep("nudge backwards", () => InputManager.Key(Key.J)); + AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100); + } + + [Test] + public void TestBasicSelect() + { + var addedObject = new HitCircle { StartTime = 100 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + moveMouseToObject(() => addedObject); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + var addedObject2 = new HitCircle + { + StartTime = 200, + Position = new Vector2(100), + }; + + AddStep("add one more hitobject", () => EditorBeatmap.Add(addedObject2)); + AddAssert("selection unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + moveMouseToObject(() => addedObject2); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject2); + } + + [Test] + public void TestMultiSelect() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObjects[0]); + + AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); + + moveMouseToObject(() => addedObjects[2]); + AddStep("click third", () => InputManager.Click(MouseButton.Left)); + AddAssert("3 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 3 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[2])); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && !EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); + + AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft)); + } + + [Test] + public void TestBasicDeselect() + { + var addedObject = new HitCircle { StartTime = 100 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + moveMouseToObject(() => addedObject); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + AddStep("click away", () => + { + InputManager.MoveMouseTo(Editor.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft + Vector2.One); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("selection lost", () => EditorBeatmap.SelectedHitObjects.Count == 0); + } + + [Test] + public void TestQuickDelete() + { + var addedObject = new HitCircle + { + StartTime = 0, + }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + moveMouseToObject(() => addedObject); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestRangeSelect() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + new HitCircle { StartTime = 500, Position = new Vector2(400) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObjects[1]); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + + moveMouseToObject(() => addedObjects[3]); + AddStep("click fourth", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Skip(1).Take(3)); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(2)); + + AddStep("clear selection", () => EditorBeatmap.SelectedHitObjects.Clear()); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(1)); + + AddStep("hold ctrl", () => InputManager.PressKey(Key.ControlLeft)); + moveMouseToObject(() => addedObjects[2]); + AddStep("click third", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(new[] { addedObjects[0], addedObjects[2] }); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + moveMouseToObject(() => addedObjects[4]); + AddStep("click fifth", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Except(new[] { addedObjects[1] })); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects); + + AddStep("clear selection", () => EditorBeatmap.SelectedHitObjects.Clear()); + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(1)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(2)); + + moveMouseToObject(() => addedObjects[2]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Take(3)); + + AddStep("release keys", () => + { + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + } + + [Test] + public void TestRangeSelectAfterExternalSelection() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("select all without mouse", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + assertSelectionIs(addedObjects); + + AddStep("hold down shift", () => InputManager.PressKey(Key.ShiftLeft)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second object", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects); + + moveMouseToObject(() => addedObjects[3]); + AddStep("click fourth object", () => InputManager.Click(MouseButton.Left)); + assertSelectionIs(addedObjects.Skip(1)); + + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + } + + private void assertSelectionIs(IEnumerable hitObjects) + => AddAssert("correct hitobjects selected", () => EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).SequenceEqual(hitObjects)); + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index b82e776164..f961fff1e5 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; @@ -30,7 +31,10 @@ namespace osu.Game.Tests.Visual.Editing Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); Beatmap.Disabled = true; - Child = new TimingScreen(); + Child = new TimingScreen + { + State = { Value = Visibility.Visible }, + }; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs index b7dcad3825..00b5c38e20 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Game.Configuration; @@ -71,7 +70,7 @@ namespace osu.Game.Tests.Visual.Gameplay var working = CreateWorkingBeatmap(rulesetInfo); Beatmap.Value = working; - SelectedMods.Value = new[] { ruleset.GetAllMods().First(m => m is ModNoFail) }; + SelectedMods.Value = new[] { ruleset.CreateMod() }; Player = CreatePlayer(ruleset); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index bc7cf8eee2..fdc3916c47 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -11,7 +11,6 @@ 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 { @@ -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 13e84e335d..7398527f57 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -42,6 +42,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestEmptyLegacyBeatmapSkinFallsBack() { CreateSkinTest(SkinInfo.Default, () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null)); + AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value)); } @@ -84,18 +85,18 @@ namespace osu.Game.Tests.Visual.Gameplay Remove(expectedComponentsAdjustmentContainer); return almostEqual(actualInfo, expectedInfo); - - static bool almostEqual(SkinnableInfo info, SkinnableInfo other) => - other != null - && info.Type == other.Type - && info.Anchor == other.Anchor - && info.Origin == other.Origin - && Precision.AlmostEquals(info.Position, other.Position) - && Precision.AlmostEquals(info.Scale, other.Scale) - && Precision.AlmostEquals(info.Rotation, other.Rotation) - && info.Children.SequenceEqual(other.Children, new FuncEqualityComparer(almostEqual)); } + private static bool almostEqual(SkinnableInfo info, SkinnableInfo other) => + other != null + && info.Type == other.Type + && info.Anchor == other.Anchor + && info.Origin == other.Origin + && Precision.AlmostEquals(info.Position, other.Position, 1) + && Precision.AlmostEquals(info.Scale, other.Scale) + && Precision.AlmostEquals(info.Rotation, other.Rotation) + && info.Children.SequenceEqual(other.Children, new FuncEqualityComparer(almostEqual)); + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => new CustomSkinWorkingBeatmap(beatmap, storyboard, Clock, Audio, currentBeatmapSkin); @@ -111,7 +112,7 @@ namespace osu.Game.Tests.Visual.Gameplay this.beatmapSkin = beatmapSkin; } - protected override ISkin GetSkin() => beatmapSkin; + protected internal override ISkin GetSkin() => beatmapSkin; } private class TestOsuRuleset : OsuRuleset 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/TestSceneDrawableScrollingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs index 75a5eec6f7..3f10d7892d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs @@ -182,7 +182,7 @@ namespace osu.Game.Tests.Visual.Gameplay { var beatmap = createBeatmap(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); - beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2; + beatmap.Difficulty.SliderMultiplier = 2; createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); AddStep("adjust time range", () => drawableRuleset.TimeRange.Value = 5000); @@ -196,7 +196,7 @@ namespace osu.Game.Tests.Visual.Gameplay { var beatmap = createBeatmap(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); - beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2; + beatmap.Difficulty.SliderMultiplier = 2; createTest(beatmap); AddStep("adjust time range", () => drawableRuleset.TimeRange.Value = 2000); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs index 5eb71e92c2..ae0decaee1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs @@ -103,6 +103,30 @@ namespace osu.Game.Tests.Visual.Gameplay checkFrameCount(0); } + [Test] + public void TestRatePreservedWhenTimeNotProgressing() + { + AddStep("set manual clock rate", () => manualClock.Rate = 1); + seekManualTo(5000); + createStabilityContainer(); + checkRate(1); + + seekManualTo(10000); + checkRate(1); + + AddWaitStep("wait some", 3); + checkRate(1); + + seekManualTo(5000); + checkRate(-1); + + AddWaitStep("wait some", 3); + checkRate(-1); + + seekManualTo(10000); + checkRate(1); + } + private const int max_frames_catchup = 50; private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () => @@ -116,6 +140,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void checkFrameCount(int frames) => AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames == frames); + private void checkRate(double rate) => + AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate == rate); + public class ClockConsumingChild : CompositeDrawable { private readonly OsuSpriteText text; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index 17fe09f2c6..0441c5641e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -40,6 +40,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddStep("add local player", () => createLeaderboardScore(playerScore, new User { Username = "You", Id = 3 }, true)); + AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value); AddSliderStep("set player score", 50, 5000000, 1222333, v => playerScore.Value = v); } @@ -83,19 +84,38 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("add frenzibyte", () => createRandomScore(new User { Username = "frenzibyte", Id = 14210502 })); } + [Test] + public void TestMaxHeight() + { + int playerNumber = 1; + AddRepeatStep("add 3 other players", () => createRandomScore(new User { Username = $"Player {playerNumber++}" }), 3); + checkHeight(4); + + AddRepeatStep("add 4 other players", () => createRandomScore(new User { Username = $"Player {playerNumber++}" }), 4); + checkHeight(8); + + AddRepeatStep("add 4 other players", () => createRandomScore(new User { Username = $"Player {playerNumber++}" }), 4); + checkHeight(8); + + void checkHeight(int panelCount) + => AddAssert($"leaderboard height is {panelCount} panels high", () => leaderboard.DrawHeight == (GameplayLeaderboardScore.PANEL_HEIGHT + leaderboard.Spacing) * panelCount); + } + private void createRandomScore(User user) => createLeaderboardScore(new BindableDouble(RNG.Next(0, 5_000_000)), user); private void createLeaderboardScore(BindableDouble score, User user, bool isTracked = false) { - var leaderboardScore = leaderboard.AddPlayer(user, isTracked); + var leaderboardScore = leaderboard.Add(user, isTracked); leaderboardScore.TotalScore.BindTo(score); } private class TestGameplayLeaderboard : GameplayLeaderboard { + public float Spacing => Flow.Spacing.Y; + public bool CheckPositionByUsername(string username, int? expectedPosition) { - var scoreItem = this.FirstOrDefault(i => i.User?.Username == username); + var scoreItem = Flow.FirstOrDefault(i => i.User?.Username == username); return scoreItem != null && scoreItem.ScorePosition == expectedPosition; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs index d69ac665cc..477ac70501 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 } /// @@ -228,7 +228,7 @@ namespace osu.Game.Tests.Visual.Gameplay var lastAction = pauseOverlay.OnRetry; pauseOverlay.OnRetry = () => triggered = true; - getButton(1).Click(); + getButton(1).TriggerClick(); pauseOverlay.OnRetry = lastAction; }); @@ -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/TestSceneGameplayRewinding.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs index 73c6970482..814b41cdbc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs @@ -55,7 +55,7 @@ namespace osu.Game.Tests.Visual.Gameplay { var beatmap = new Beatmap { - BeatmapInfo = { BaseDifficulty = { ApproachRate = 9 } }, + Difficulty = { ApproachRate = 9 }, }; for (int i = 0; i < 15; i++) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs new file mode 100644 index 0000000000..fccc1a377c --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -0,0 +1,135 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.UI; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneGameplaySampleTriggerSource : PlayerTestScene + { + private TestGameplaySampleTriggerSource sampleTriggerSource; + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + private Beatmap beatmap; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty { CircleSize = 6, SliderMultiplier = 3 }, + Ruleset = ruleset + } + }; + + const double start_offset = 8000; + const double spacing = 2000; + + double t = start_offset; + beatmap.HitObjects.AddRange(new[] + { + new HitCircle + { + // intentionally start objects a bit late so we can test the case of no alive objects. + StartTime = t += spacing, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }, + new HitCircle + { + StartTime = t += spacing, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) } + }, + new HitCircle + { + StartTime = t += spacing, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, + SampleControlPoint = new SampleControlPoint { SampleBank = "soft" }, + }, + new HitCircle + { + StartTime = t + spacing, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) }, + SampleControlPoint = new SampleControlPoint { SampleBank = "soft" }, + }, + }); + + return beatmap; + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("Add trigger source", () => Player.HUDOverlay.Add(sampleTriggerSource = new TestGameplaySampleTriggerSource(Player.DrawableRuleset.Playfield.HitObjectContainer))); + } + + [Test] + public void TestCorrectHitObject() + { + HitObjectLifetimeEntry nextObjectEntry = null; + + AddAssert("no alive objects", () => getNextAliveObject() == null); + + AddAssert("check initially correct object", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[0]); + + AddUntilStep("get next object", () => + { + var nextDrawableObject = getNextAliveObject(); + + if (nextDrawableObject != null) + { + nextObjectEntry = nextDrawableObject.Entry; + InputManager.MoveMouseTo(nextDrawableObject.ScreenSpaceDrawQuad.Centre); + return true; + } + + return false; + }); + + AddUntilStep("hit first hitobject", () => + { + InputManager.Click(MouseButton.Left); + return nextObjectEntry.Result.HasResult; + }); + + AddAssert("check correct object after hit", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[1]); + + AddUntilStep("check correct object after miss", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[2]); + AddUntilStep("check correct object after miss", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[3]); + + AddUntilStep("no alive objects", () => getNextAliveObject() == null); + AddAssert("check correct object after none alive", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[3]); + } + + private DrawableHitObject getNextAliveObject() => + Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(); + + [Test] + public void TestSampleTriggering() + { + AddRepeatStep("trigger sample", () => sampleTriggerSource.Play(), 10); + } + + public class TestGameplaySampleTriggerSource : GameplaySampleTriggerSource + { + public TestGameplaySampleTriggerSource(HitObjectContainer hitObjectContainer) + : base(hitObjectContainer) + { + } + + public new HitObject GetMostValidObject() => base.GetMostValidObject(); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index b7e92a79a0..4b54cd3510 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -12,12 +12,15 @@ using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Skinning; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneHUDOverlay : OsuManualInputManagerTestScene { + private OsuConfigManager localConfig; + private HUDOverlay hudOverlay; [Cached] @@ -30,8 +33,14 @@ namespace osu.Game.Tests.Visual.Gameplay private Drawable hideTarget => hudOverlay.KeyCounter; private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); - [Resolved] - private OsuConfigManager config { get; set; } + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage)); + } + + [SetUp] + public void SetUp() => Schedule(() => localConfig.SetValue(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always)); [Test] public void TestComboCounterIncrementing() @@ -84,11 +93,7 @@ namespace osu.Game.Tests.Visual.Gameplay { createNew(); - HUDVisibilityMode originalConfigValue = HUDVisibilityMode.HideDuringGameplay; - - AddStep("get original config value", () => originalConfigValue = config.Get(OsuSetting.HUDVisibilityMode)); - - AddStep("set hud to never show", () => config.SetValue(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); + AddStep("set hud to never show", () => localConfig.SetValue(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); AddUntilStep("wait for fade", () => !hideTarget.IsPresent); @@ -97,37 +102,28 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("stop trigering", () => InputManager.ReleaseKey(Key.ControlLeft)); AddUntilStep("wait for fade", () => !hideTarget.IsPresent); - - AddStep("set original config value", () => config.SetValue(OsuSetting.HUDVisibilityMode, originalConfigValue)); } [Test] public void TestExternalHideDoesntAffectConfig() { - HUDVisibilityMode originalConfigValue = HUDVisibilityMode.HideDuringGameplay; - createNew(); - AddStep("get original config value", () => originalConfigValue = config.Get(OsuSetting.HUDVisibilityMode)); - AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); - AddAssert("config unchanged", () => originalConfigValue == config.Get(OsuSetting.HUDVisibilityMode)); + AddAssert("config unchanged", () => localConfig.GetBindable(OsuSetting.HUDVisibilityMode).IsDefault); AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); - AddAssert("config unchanged", () => originalConfigValue == config.Get(OsuSetting.HUDVisibilityMode)); + AddAssert("config unchanged", () => localConfig.GetBindable(OsuSetting.HUDVisibilityMode).IsDefault); } [Test] public void TestChangeHUDVisibilityOnHiddenKeyCounter() { - bool keyCounterVisibleValue = false; - createNew(); - AddStep("save keycounter visible value", () => keyCounterVisibleValue = config.Get(OsuSetting.KeyOverlay)); - AddStep("set keycounter visible false", () => + AddStep("hide key overlay", () => { - config.SetValue(OsuSetting.KeyOverlay, false); + localConfig.SetValue(OsuSetting.KeyOverlay, false); hudOverlay.KeyCounter.AlwaysVisible.Value = false; }); @@ -138,8 +134,20 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); AddUntilStep("hidetarget is visible", () => hideTarget.IsPresent); AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); + } - AddStep("return value", () => config.SetValue(OsuSetting.KeyOverlay, keyCounterVisibleValue)); + [Test] + public void TestHiddenHUDDoesntBlockSkinnableComponentsLoad() + { + AddStep("set hud to never show", () => localConfig.SetValue(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); + + createNew(); + + AddUntilStep("wait for hud load", () => hudOverlay.IsLoaded); + AddUntilStep("wait for components to be hidden", () => !hudOverlay.ChildrenOfType().Single().IsPresent); + + AddStep("reload components", () => hudOverlay.ChildrenOfType().Single().Reload()); + AddUntilStep("skinnable components loaded", () => hudOverlay.ChildrenOfType().Single().ComponentsLoaded); } private void createNew(Action action = null) @@ -158,5 +166,11 @@ namespace osu.Game.Tests.Visual.Gameplay Child = hudOverlay; }); } + + protected override void Dispose(bool isDisposing) + { + localConfig?.Dispose(); + base.Dispose(isDisposing); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 7accaef818..1ba0965ceb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; @@ -137,6 +138,23 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("no circle added", () => !this.ChildrenOfType().Any()); } + [Test] + public void TestClear() + { + AddStep("OD 1", () => recreateDisplay(new OsuHitWindows(), 1)); + + AddStep("hit", () => newJudgement(0.2D)); + AddAssert("bar added", () => this.ChildrenOfType().All( + meter => meter.ChildrenOfType().Count() == 1)); + AddAssert("circle added", () => this.ChildrenOfType().All( + meter => meter.ChildrenOfType().Count() == 1)); + + AddStep("clear", () => this.ChildrenOfType().ForEach(meter => meter.Clear())); + + AddAssert("bar cleared", () => !this.ChildrenOfType().Any()); + AddAssert("colour cleared", () => !this.ChildrenOfType().Any()); + } + private void recreateDisplay(HitWindows hitWindows, float overallDifficulty) { hitWindows?.SetDifficulty(overallDifficulty); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs index 6de85499c5..0a39d94027 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Input.Bindings; @@ -80,13 +81,13 @@ namespace osu.Game.Tests.Visual.Gameplay { public bool ReceivedAction; - public bool OnPressed(TestAction action) + public bool OnPressed(KeyBindingPressEvent e) { - ReceivedAction = action == TestAction.Down; + ReceivedAction = e.Action == TestAction.Down; return true; } - public void OnReleased(TestAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs index 4fa4c00981..b308f3d7d8 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs @@ -3,7 +3,6 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Bindables; using osu.Game.Overlays; using osu.Game.Rulesets; @@ -66,7 +65,6 @@ namespace osu.Game.Tests.Visual.Gameplay protected class OverlayTestPlayer : TestPlayer { public new OverlayActivation OverlayActivationMode => base.OverlayActivationMode.Value; - public new Bindable LocalUserPlaying => base.LocalUserPlaying; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs new file mode 100644 index 0000000000..ce5cd629e0 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public class TestSceneParticleSpewer : OsuTestScene + { + private TestParticleSpewer spewer; + + [Resolved] + private SkinManager skinManager { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + Child = spewer = createSpewer(); + + AddToggleStep("toggle spawning", value => spewer.Active.Value = value); + AddSliderStep("particle gravity", 0f, 1f, 0f, value => spewer.Gravity = value); + AddSliderStep("particle velocity", 0f, 1f, 0.5f, value => spewer.MaxVelocity = value); + AddStep("move to new location", () => + { + spewer.TransformTo(nameof(spewer.SpawnPosition), new Vector2(RNG.NextSingle(), RNG.NextSingle()), 1000, Easing.Out); + }); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create spewer", () => Child = spewer = createSpewer()); + } + + [Test] + public void TestPresence() + { + AddStep("start spewer", () => spewer.Active.Value = true); + AddAssert("is present", () => spewer.IsPresent); + + AddWaitStep("wait for some particles", 3); + AddStep("stop spewer", () => spewer.Active.Value = false); + + AddWaitStep("wait for clean screen", 8); + AddAssert("is not present", () => !spewer.IsPresent); + } + + [Test] + public void TestTimeJumps() + { + ManualClock testClock = new ManualClock(); + + AddStep("prepare clock", () => + { + testClock.CurrentTime = TestParticleSpewer.MAX_DURATION * -3; + spewer.Clock = new FramedClock(testClock); + }); + AddStep("start spewer", () => spewer.Active.Value = true); + AddAssert("spawned first particle", () => spewer.TotalCreatedParticles == 1); + + AddStep("move clock forward", () => testClock.CurrentTime = TestParticleSpewer.MAX_DURATION * 3); + AddAssert("spawned second particle", () => spewer.TotalCreatedParticles == 2); + + AddStep("move clock backwards", () => testClock.CurrentTime = TestParticleSpewer.MAX_DURATION * -1); + AddAssert("spawned third particle", () => spewer.TotalCreatedParticles == 3); + } + + private TestParticleSpewer createSpewer() => + new TestParticleSpewer(skinManager.DefaultLegacySkin.GetTexture("star2")) + { + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Both, + RelativeSizeAxes = Axes.Both, + Position = new Vector2(0.5f), + Size = new Vector2(0.5f), + }; + + private class TestParticleSpewer : ParticleSpewer + { + public const int MAX_DURATION = 1500; + private const int rate = 250; + + public int TotalCreatedParticles { get; private set; } + + public float Gravity; + + public float MaxVelocity = 0.25f; + + public Vector2 SpawnPosition { get; set; } = new Vector2(0.5f); + + protected override float ParticleGravity => Gravity; + + public TestParticleSpewer(Texture texture) + : base(texture, rate, MAX_DURATION) + { + } + + protected override FallingParticle CreateParticle() + { + TotalCreatedParticles++; + + return new FallingParticle + { + Velocity = new Vector2( + RNG.NextSingle(-MaxVelocity, MaxVelocity), + RNG.NextSingle(-MaxVelocity, MaxVelocity) + ), + StartPosition = SpawnPosition, + Duration = RNG.NextSingle(MAX_DURATION), + StartAngle = RNG.NextSingle(MathF.PI * 2), + EndAngle = RNG.NextSingle(MathF.PI * 2), + EndScale = RNG.NextSingle(0.5f, 1.5f) + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index bddc7ab731..04676f656f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -3,10 +3,12 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Rulesets; @@ -39,6 +41,45 @@ namespace osu.Game.Tests.Visual.Gameplay confirmClockRunning(true); } + [Test] + public void TestPauseWithLargeOffset() + { + double lastTime; + bool alwaysGoingForward = true; + + AddStep("force large offset", () => + { + var offset = (BindableDouble)LocalConfig.GetBindable(OsuSetting.AudioOffset); + + // use a large negative offset to avoid triggering a fail from forwards seeking. + offset.MinValue = -5000; + offset.Value = -5000; + }); + + AddStep("add time forward check hook", () => + { + lastTime = double.MinValue; + alwaysGoingForward = true; + + Player.OnUpdate += _ => + { + double currentTime = Player.GameplayClockContainer.CurrentTime; + alwaysGoingForward &= currentTime >= lastTime; + lastTime = currentTime; + }; + }); + + AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); + + pauseAndConfirm(); + + resumeAndConfirm(); + + AddAssert("time didn't go backwards", () => alwaysGoingForward); + + AddStep("reset offset", () => LocalConfig.SetValue(OsuSetting.AudioOffset, 0.0)); + } + [Test] public void TestPauseResume() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs new file mode 100644 index 0000000000..4c48d52acd --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs @@ -0,0 +1,108 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestScenePerformancePointsCounter : OsuTestScene + { + [Cached] + private GameplayState gameplayState; + + [Cached] + private ScoreProcessor scoreProcessor; + + private int iteration; + private PerformancePointsCounter counter; + + public TestScenePerformancePointsCounter() + { + var ruleset = CreateRuleset(); + + Debug.Assert(ruleset != null); + + var beatmap = CreateWorkingBeatmap(ruleset.RulesetInfo) + .GetPlayableBeatmap(ruleset.RulesetInfo); + + gameplayState = new GameplayState(beatmap, ruleset); + scoreProcessor = new ScoreProcessor(); + } + + protected override Ruleset CreateRuleset() => new OsuRuleset(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create counter", () => + { + iteration = 0; + + Child = counter = new PerformancePointsCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(5), + }; + }); + } + + [Test] + public void TestBasicCounting() + { + int previousValue = 0; + + AddAssert("counter displaying zero", () => counter.Current.Value == 0); + + AddRepeatStep("Add judgement", applyOneJudgement, 10); + + AddUntilStep("counter non-zero", () => counter.Current.Value > 0); + AddUntilStep("counter opaque", () => counter.Child.Alpha == 1); + + AddStep("Revert judgement", () => + { + previousValue = counter.Current.Value; + + scoreProcessor.RevertResult(new JudgementResult(new HitObject(), new OsuJudgement())); + }); + + AddUntilStep("counter decreased", () => counter.Current.Value < previousValue); + + AddStep("Add judgement", applyOneJudgement); + + AddUntilStep("counter non-zero", () => counter.Current.Value > 0); + } + + private void applyOneJudgement() + { + var scoreInfo = gameplayState.Score.ScoreInfo; + + scoreInfo.MaxCombo = iteration * 1000; + scoreInfo.Accuracy = 1; + scoreInfo.Statistics[HitResult.Great] = iteration * 1000; + + scoreProcessor.ApplyResult(new OsuJudgementResult(new HitObject + { + StartTime = iteration * 10000, + }, new OsuJudgement()) + { + Type = HitResult.Perfect, + }); + + iteration++; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 8160a62991..aee15a145c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -205,7 +205,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen()); AddStep("mouse in centre", () => InputManager.MoveMouseTo(loader.ScreenSpaceDrawQuad.Centre)); AddUntilStep("wait for player to be current", () => player.IsCurrentScreen()); - AddStep("retrieve mods", () => playerMod1 = (TestMod)player.Mods.Value.Single()); + AddStep("retrieve mods", () => playerMod1 = (TestMod)player.GameplayState.Mods.Single()); AddAssert("game mods not applied", () => gameMod.Applied == false); AddAssert("player mods applied", () => playerMod1.Applied); @@ -217,7 +217,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("wait for player to be current", () => player.IsCurrentScreen()); - AddStep("retrieve mods", () => playerMod2 = (TestMod)player.Mods.Value.Single()); + AddStep("retrieve mods", () => playerMod2 = (TestMod)player.GameplayState.Mods.Single()); AddAssert("game mods not applied", () => gameMod.Applied == false); AddAssert("player has different mods", () => playerMod1 != playerMod2); AddAssert("player mods applied", () => playerMod2.Applied); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index d9c0544d3c..5ff2e9c439 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -1,38 +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; 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 : OsuPlayerTestScene + 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); - CreateTest(() => allowFail = false); + createPlayerTest(); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -52,7 +72,7 @@ namespace osu.Game.Tests.Visual.Gameplay { prepareTokenResponse(true); - CreateTest(() => allowFail = false); + createPlayerTest(); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -71,7 +91,7 @@ namespace osu.Game.Tests.Visual.Gameplay { prepareTokenResponse(false); - CreateTest(() => allowFail = false); + createPlayerTest(); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -88,7 +108,7 @@ namespace osu.Game.Tests.Visual.Gameplay { prepareTokenResponse(true); - CreateTest(() => allowFail = true); + createPlayerTest(true); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -103,7 +123,7 @@ namespace osu.Game.Tests.Visual.Gameplay { prepareTokenResponse(true); - CreateTest(() => allowFail = true); + createPlayerTest(true); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -120,7 +140,7 @@ namespace osu.Game.Tests.Visual.Gameplay { prepareTokenResponse(true); - CreateTest(() => allowFail = false); + createPlayerTest(); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -133,7 +153,7 @@ namespace osu.Game.Tests.Visual.Gameplay { prepareTokenResponse(true); - CreateTest(() => allowFail = false); + createPlayerTest(); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -143,18 +163,49 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure failing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == false); } - private void addFakeHit() + [Test] + public void TestNoSubmissionOnLocalBeatmap() { - AddUntilStep("wait for first result", () => Player.Results.Count > 0); + prepareTokenResponse(true); - AddStep("force successfuly hit", () => + createPlayerTest(false, r => { - Player.ScoreProcessor.RevertResult(Player.Results.First()); - Player.ScoreProcessor.ApplyResult(new OsuJudgementResult(Beatmap.Value.Beatmap.HitObjects.First(), new OsuJudgement()) - { - Type = HitResult.Great, - }); + 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) @@ -177,5 +228,19 @@ namespace osu.Game.Tests.Visual.Gameplay }; }); } + + 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/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index 1809332bce..5e2374cbcb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.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.Graphics; using osu.Game.Online; @@ -8,34 +9,29 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Scoring; using osu.Game.Users; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Screens.Ranking; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneReplayDownloadButton : OsuTestScene + public class TestSceneReplayDownloadButton : OsuManualInputManagerTestScene { [Resolved] private RulesetStore rulesets { get; set; } private TestReplayDownloadButton downloadButton; - public TestSceneReplayDownloadButton() + [Test] + public void TestDisplayStates() { - createButton(true); - AddStep(@"downloading state", () => downloadButton.SetDownloadState(DownloadState.Downloading)); - AddStep(@"locally available state", () => downloadButton.SetDownloadState(DownloadState.LocallyAvailable)); - AddStep(@"not downloaded state", () => downloadButton.SetDownloadState(DownloadState.NotDownloaded)); - createButton(false); - createButtonNoScore(); - } - - private void createButton(bool withReplay) - { - AddStep(withReplay ? @"create button with replay" : "create button without replay", () => + AddStep(@"create button with replay", () => { - Child = downloadButton = new TestReplayDownloadButton(getScoreInfo(withReplay)) + Child = downloadButton = new TestReplayDownloadButton(getScoreInfo(true)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -43,9 +39,81 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("wait for load", () => downloadButton.IsLoaded); + + AddStep(@"downloading state", () => downloadButton.SetDownloadState(DownloadState.Downloading)); + AddStep(@"locally available state", () => downloadButton.SetDownloadState(DownloadState.LocallyAvailable)); + AddStep(@"not downloaded state", () => downloadButton.SetDownloadState(DownloadState.NotDownloaded)); } - private void createButtonNoScore() + [Test] + public void TestButtonWithReplayStartsDownload() + { + bool downloadStarted = false; + bool downloadFinished = false; + + AddStep(@"create button with replay", () => + { + downloadStarted = false; + downloadFinished = false; + + Child = downloadButton = new TestReplayDownloadButton(getScoreInfo(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + + downloadButton.State.BindValueChanged(state => + { + switch (state.NewValue) + { + case DownloadState.Downloading: + downloadStarted = true; + break; + } + + switch (state.OldValue) + { + case DownloadState.Downloading: + downloadFinished = true; + break; + } + }); + }); + + AddUntilStep("wait for load", () => downloadButton.IsLoaded); + + AddAssert("state is available", () => downloadButton.State.Value == DownloadState.NotDownloaded); + + AddStep("click button", () => + { + InputManager.MoveMouseTo(downloadButton); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("state entered downloading", () => downloadStarted); + AddUntilStep("state left downloading", () => downloadFinished); + } + + [Test] + public void TestButtonWithoutReplay() + { + AddStep("create button without replay", () => + { + Child = downloadButton = new TestReplayDownloadButton(getScoreInfo(false)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + + AddUntilStep("wait for load", () => downloadButton.IsLoaded); + + AddAssert("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); + } + + [Test] + public void CreateButtonWithNoScore() { AddStep("create button with null score", () => { @@ -57,6 +125,9 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("wait for load", () => downloadButton.IsLoaded); + + AddAssert("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); } private ScoreInfo getScoreInfo(bool replayAvailable) @@ -78,6 +149,8 @@ namespace osu.Game.Tests.Visual.Gameplay { public void SetDownloadState(DownloadState state) => State.Value = state; + public new Bindable State => base.State; + public TestReplayDownloadButton(ScoreInfo score) : base(score) { 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/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index b38f7a998d..c8040f42f0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.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; @@ -17,6 +18,8 @@ using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; using osu.Game.Replays; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.UI; using osu.Game.Scoring; @@ -38,7 +41,7 @@ namespace osu.Game.Tests.Visual.Gameplay private TestReplayRecorder recorder; [Cached] - private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap()); + private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); [SetUp] public void SetUp() => Schedule(() => @@ -54,7 +57,11 @@ namespace osu.Game.Tests.Visual.Gameplay { recordingManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - Recorder = recorder = new TestReplayRecorder(new Score { Replay = replay }) + Recorder = recorder = new TestReplayRecorder(new Score + { + Replay = replay, + ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo } + }) { ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), }, @@ -222,13 +229,13 @@ namespace osu.Game.Tests.Visual.Gameplay return base.OnMouseMove(e); } - public bool OnPressed(TestAction action) + public bool OnPressed(KeyBindingPressEvent e) { box.Colour = Color4.White; return true; } - public void OnReleased(TestAction action) + public void OnReleased(KeyBindingReleaseEvent e) { box.Colour = Color4.Black; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs index 6e338b7202..3545fc96e8 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,6 +14,8 @@ using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; using osu.Game.Replays; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.UI; using osu.Game.Scoring; @@ -30,7 +33,7 @@ namespace osu.Game.Tests.Visual.Gameplay private readonly TestRulesetInputManager recordingManager; [Cached] - private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap()); + private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); public TestSceneReplayRecording() { @@ -45,7 +48,11 @@ namespace osu.Game.Tests.Visual.Gameplay { recordingManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - Recorder = new TestReplayRecorder(new Score { Replay = replay }) + Recorder = new TestReplayRecorder(new Score + { + Replay = replay, + ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo } + }) { ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos) }, @@ -155,13 +162,13 @@ namespace osu.Game.Tests.Visual.Gameplay return base.OnMouseMove(e); } - public bool OnPressed(TestAction action) + public bool OnPressed(KeyBindingPressEvent e) { box.Colour = Color4.White; return true; } - public void OnReleased(TestAction action) + public void OnReleased(KeyBindingReleaseEvent e) { box.Colour = Color4.Black; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index f29fbbf52b..3e8ba69e01 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Visual.Gameplay Children = new[] { new ExposedSkinnableDrawable("default", _ => new DefaultBox()), - new ExposedSkinnableDrawable("available", _ => new DefaultBox(), ConfineMode.ScaleToFit), + new ExposedSkinnableDrawable("available", _ => new DefaultBox()), new ExposedSkinnableDrawable("available", _ => new DefaultBox(), ConfineMode.NoScaling) } }, @@ -168,7 +168,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void Disable() { allow = false; - OnSourceChanged(); + TriggerSourceChanged(); } public SwitchableSkinProvidingContainer(ISkin skin) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs index 606395c289..9750838433 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs @@ -74,14 +74,14 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestAddControlPoint() { AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100)))); - AddStep("add point", () => path.ControlPoints.Add(new PathControlPoint { Position = { Value = new Vector2(100) } })); + AddStep("add point", () => path.ControlPoints.Add(new PathControlPoint { Position = new Vector2(100) })); } [Test] public void TestInsertControlPoint() { AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(100)))); - AddStep("insert point", () => path.ControlPoints.Insert(1, new PathControlPoint { Position = { Value = new Vector2(0, 100) } })); + AddStep("insert point", () => path.ControlPoints.Insert(1, new PathControlPoint { Position = new Vector2(0, 100) })); } [Test] @@ -95,14 +95,14 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestChangePathType() { AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); - AddStep("change type to bezier", () => path.ControlPoints[0].Type.Value = PathType.Bezier); + AddStep("change type to bezier", () => path.ControlPoints[0].Type = PathType.Bezier); } [Test] public void TestAddSegmentByChangingType() { AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0)))); - AddStep("change second point type to bezier", () => path.ControlPoints[1].Type.Value = PathType.Bezier); + AddStep("change second point type to bezier", () => path.ControlPoints[1].Type = PathType.Bezier); } [Test] @@ -111,10 +111,10 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("create path", () => { path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); - path.ControlPoints[1].Type.Value = PathType.Bezier; + path.ControlPoints[1].Type = PathType.Bezier; }); - AddStep("change second point type to null", () => path.ControlPoints[1].Type.Value = null); + AddStep("change second point type to null", () => path.ControlPoints[1].Type = null); } [Test] @@ -123,7 +123,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("create path", () => { path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); - path.ControlPoints[1].Type.Value = PathType.Bezier; + path.ControlPoints[1].Type = PathType.Bezier; }); AddStep("remove second point", () => path.ControlPoints.RemoveAt(1)); @@ -185,8 +185,8 @@ namespace osu.Game.Tests.Visual.Gameplay private List createSegment(PathType type, params Vector2[] controlPoints) { - var points = controlPoints.Select(p => new PathControlPoint { Position = { Value = p } }).ToList(); - points[0].Type.Value = type; + var points = controlPoints.Select(p => new PathControlPoint { Position = p }).ToList(); + points[0].Type = type; return points; } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 7584d67afe..21c5d89aca 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -13,6 +13,7 @@ using osu.Game.Online.Spectator; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.UI; +using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Visual.Multiplayer; @@ -25,41 +26,43 @@ namespace osu.Game.Tests.Visual.Gameplay { private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" }; - [Cached(typeof(SpectatorClient))] - private TestSpectatorClient testSpectatorClient = new TestSpectatorClient(); - [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); // used just to show beatmap card for the time being. protected override bool UseOnlineAPI => true; - private SoloSpectator spectatorScreen; - [Resolved] private OsuGameBase game { get; set; } - private BeatmapSetInfo importedBeatmap; + private TestSpectatorClient spectatorClient; + private SoloSpectator spectatorScreen; + private BeatmapSetInfo importedBeatmap; private int importedBeatmapId; - public override void SetUpSteps() + [SetUpSteps] + public void SetupSteps() { - base.SetUpSteps(); + DependenciesScreen dependenciesScreen = null; + + AddStep("load dependencies", () => + { + spectatorClient = new TestSpectatorClient(); + + // The screen gets suspended so it stops receiving updates. + Child = spectatorClient; + + LoadScreen(dependenciesScreen = new DependenciesScreen(spectatorClient)); + }); + + AddUntilStep("wait for dependencies to load", () => dependenciesScreen.IsLoaded); AddStep("import beatmap", () => { importedBeatmap = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result; importedBeatmapId = importedBeatmap.Beatmaps.First(b => b.RulesetID == 0).OnlineBeatmapID ?? -1; }); - - AddStep("add streaming client", () => - { - Remove(testSpectatorClient); - Add(testSpectatorClient); - }); - - finish(); } [Test] @@ -206,22 +209,36 @@ namespace osu.Game.Tests.Visual.Gameplay 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)); + private void start(int? beatmapId = null) => AddStep("start play", () => spectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); - private void finish() => AddStep("end play", () => testSpectatorClient.EndPlay(streamingUser.Id)); + private void finish() => AddStep("end play", () => spectatorClient.EndPlay(streamingUser.Id)); private void checkPaused(bool state) => AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); private void sendFrames(int count = 10) { - AddStep("send frames", () => testSpectatorClient.SendFrames(streamingUser.Id, count)); + AddStep("send frames", () => spectatorClient.SendFrames(streamingUser.Id, count)); } private void loadSpectatingScreen() { - AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser))); + AddStep("load spectator", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser))); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded); } + + /// + /// Used for the sole purpose of adding as a resolvable dependency. + /// + private class DependenciesScreen : OsuScreen + { + [Cached(typeof(SpectatorClient))] + public readonly TestSpectatorClient Client; + + public DependenciesScreen(TestSpectatorClient client) + { + Client = client; + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 469f594fdc..b4de060578 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -25,6 +25,8 @@ using osu.Game.Online.Spectator; using osu.Game.Replays; using osu.Game.Replays.Legacy; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.UI; @@ -62,7 +64,7 @@ namespace osu.Game.Tests.Visual.Gameplay private SpectatorClient spectatorClient { get; set; } [Cached] - private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap()); + private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); [SetUp] public void SetUp() => Schedule(() => @@ -179,7 +181,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); } } @@ -279,13 +281,13 @@ namespace osu.Game.Tests.Visual.Gameplay return base.OnMouseMove(e); } - public bool OnPressed(TestAction action) + public bool OnPressed(KeyBindingPressEvent e) { box.Colour = Color4.White; return true; } - public void OnReleased(TestAction action) + public void OnReleased(KeyBindingReleaseEvent e) { box.Colour = Color4.Black; } @@ -354,7 +356,7 @@ namespace osu.Game.Tests.Visual.Gameplay internal class TestReplayRecorder : ReplayRecorder { public TestReplayRecorder() - : base(new Score()) + : base(new Score { ScoreInfo = { BeatmapInfo = new BeatmapInfo() } }) { } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index 3ed274690e..48a97d54f7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -90,8 +90,12 @@ namespace osu.Game.Tests.Visual.Gameplay CreateTest(() => { AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true); - AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300); + + // Fail occurs at 164ms with the provided beatmap. + // Fail animation runs for 2.5s realtime but the gameplay time change is *variable* due to the frequency transform being applied, so we need a bit of lenience. + AddStep("set storyboard duration to 0.6s", () => currentStoryboardDuration = 600); }); + AddUntilStep("wait for fail", () => Player.HasFailed); AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); diff --git a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs index f71d13ed35..bfea97410a 100644 --- a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs +++ b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Framework.Screens; using osu.Game.Screens; using osu.Game.Screens.Menu; using osuTK; @@ -21,6 +20,8 @@ namespace osu.Game.Tests.Visual.Menus protected OsuScreenStack IntroStack; + private IntroScreen intro; + protected IntroTestScene() { Children = new Drawable[] @@ -39,7 +40,11 @@ namespace osu.Game.Tests.Visual.Menus Position = new Vector2(0.5f), } }; + } + [Test] + public virtual void TestPlayIntro() + { AddStep("restart sequence", () => { logo.FinishTransforms(); @@ -52,12 +57,12 @@ namespace osu.Game.Tests.Visual.Menus RelativeSizeAxes = Axes.Both, }); - IntroStack.Push(CreateScreen()); + IntroStack.Push(intro = CreateScreen()); }); - AddUntilStep("wait for menu", () => IntroStack.CurrentScreen is MainMenu); + AddUntilStep("wait for menu", () => intro.DidLoadMenu); } - protected abstract IScreen CreateScreen(); + protected abstract IntroScreen CreateScreen(); } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs index 107734cc8d..ffc99185fb 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; -using osu.Framework.Screens; using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.Menus @@ -10,6 +9,6 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public class TestSceneIntroCircles : IntroTestScene { - protected override IScreen CreateScreen() => new IntroCircles(); + protected override IntroScreen CreateScreen() => new IntroCircles(); } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs index df79584167..8f01e0321b 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; -using osu.Framework.Screens; using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.Menus @@ -10,6 +9,6 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public class TestSceneIntroTriangles : IntroTestScene { - protected override IScreen CreateScreen() => new IntroTriangles(); + protected override IntroScreen CreateScreen() => new IntroTriangles(); } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs index 5f135febf4..9081be3dd6 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; -using osu.Framework.Screens; using osu.Framework.Utils; using osu.Game.Screens.Menu; @@ -11,10 +10,12 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public class TestSceneIntroWelcome : IntroTestScene { - protected override IScreen CreateScreen() => new IntroWelcome(); + protected override IntroScreen CreateScreen() => new IntroWelcome(); - public TestSceneIntroWelcome() + public override void TestPlayIntro() { + base.TestPlayIntro(); + AddUntilStep("wait for load", () => MusicController.TrackLoaded); AddAssert("correct track", () => Precision.AlmostEquals(MusicController.CurrentTrack.Length, 48000, 1)); AddAssert("check if menu music loops", () => MusicController.CurrentTrack.Looping); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoginPanel.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoginPanel.cs new file mode 100644 index 0000000000..4754a73f83 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoginPanel.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays.Login; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public class TestSceneLoginPanel : OsuManualInputManagerTestScene + { + private LoginPanel loginPanel; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create login dialog", () => + { + Add(loginPanel = new LoginPanel + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + }); + }); + } + + [Test] + public void TestLoginSuccess() + { + AddStep("logout", () => API.Logout()); + + AddStep("enter password", () => loginPanel.ChildrenOfType().First().Text = "password"); + AddStep("submit", () => loginPanel.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + } + + [Test] + public void TestLoginFailure() + { + AddStep("logout", () => + { + API.Logout(); + ((DummyAPIAccess)API).FailNextLogin(); + }); + + AddStep("enter password", () => loginPanel.ChildrenOfType().First().Text = "password"); + AddStep("submit", () => loginPanel.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs index aaf3323432..79dfe79299 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs @@ -10,7 +10,6 @@ using osu.Game.Database; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Tests.Resources; -using osu.Game.Tests.Visual.Navigation; namespace osu.Game.Tests.Visual.Menus { @@ -54,7 +53,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("import beatmap with track", () => { var setWithTrack = Game.BeatmapManager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).Result; - Beatmap.Value = Game.BeatmapManager.GetWorkingBeatmap(setWithTrack.Beatmaps.First()); + Beatmap.Value = Game.BeatmapManager.GetWorkingBeatmap(setWithTrack.Value.Beatmaps.First()); }); AddStep("bind to track change", () => diff --git a/osu.Game.Tests/Visual/Menus/TestSceneSideOverlays.cs b/osu.Game.Tests/Visual/Menus/TestSceneSideOverlays.cs new file mode 100644 index 0000000000..e34ec6c46a --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneSideOverlays.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Menus +{ + public class TestSceneSideOverlays : OsuGameTestScene + { + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddAssert("no screen offset applied", () => Game.ScreenOffsetContainer.X == 0f); + AddUntilStep("wait for overlays", () => Game.Settings.IsLoaded && Game.Notifications.IsLoaded); + } + + [Test] + public void TestScreenOffsettingOnSettingsOverlay() + { + foreach (var scalingMode in Enum.GetValues(typeof(ScalingMode)).Cast()) + { + AddStep($"set scaling mode to {scalingMode}", () => + { + Game.LocalConfig.SetValue(OsuSetting.Scaling, scalingMode); + + if (scalingMode != ScalingMode.Off) + { + Game.LocalConfig.SetValue(OsuSetting.ScalingSizeX, 0.5f); + Game.LocalConfig.SetValue(OsuSetting.ScalingSizeY, 0.5f); + } + }); + + AddStep("open settings", () => Game.Settings.Show()); + AddUntilStep("right screen offset applied", () => Precision.AlmostEquals(Game.ScreenOffsetContainer.X, SettingsPanel.WIDTH * TestOsuGame.SIDE_OVERLAY_OFFSET_RATIO)); + + AddStep("hide settings", () => Game.Settings.Hide()); + AddUntilStep("screen offset removed", () => Game.ScreenOffsetContainer.X == 0f); + } + } + + [Test] + public void TestScreenOffsettingOnNotificationOverlay() + { + foreach (var scalingMode in Enum.GetValues(typeof(ScalingMode)).Cast()) + { + if (scalingMode != ScalingMode.Off) + { + AddStep($"set scaling mode to {scalingMode}", () => + { + Game.LocalConfig.SetValue(OsuSetting.Scaling, scalingMode); + Game.LocalConfig.SetValue(OsuSetting.ScalingSizeX, 0.5f); + Game.LocalConfig.SetValue(OsuSetting.ScalingSizeY, 0.5f); + }); + } + + AddStep("open notifications", () => Game.Notifications.Show()); + AddUntilStep("right screen offset applied", () => Precision.AlmostEquals(Game.ScreenOffsetContainer.X, -NotificationOverlay.WIDTH * TestOsuGame.SIDE_OVERLAY_OFFSET_RATIO)); + + AddStep("hide notifications", () => Game.Notifications.Hide()); + AddUntilStep("screen offset removed", () => Game.ScreenOffsetContainer.X == 0f); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs new file mode 100644 index 0000000000..af874cec91 --- /dev/null +++ b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual.Mods +{ + public class TestSceneModFailCondition : ModTestScene + { + private bool restartRequested; + + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + protected override TestPlayer CreateModPlayer(Ruleset ruleset) + { + var player = base.CreateModPlayer(ruleset); + player.RestartRequested = () => restartRequested = true; + return player; + } + + protected override bool AllowFail => true; + + [SetUpSteps] + public void SetUp() + { + AddStep("reset flag", () => restartRequested = false); + } + + [Test] + public void TestRestartOnFailDisabled() => CreateModTest(new ModTestData + { + Autoplay = false, + Mod = new OsuModSuddenDeath(), + PassCondition = () => !restartRequested && Player.ChildrenOfType().Single().State.Value == Visibility.Visible + }); + + [Test] + public void TestRestartOnFailEnabled() => CreateModTest(new ModTestData + { + Autoplay = false, + Mod = new OsuModSuddenDeath + { + Restart = { Value = true } + }, + PassCondition = () => restartRequested && Player.ChildrenOfType().Single().State.Value == Visibility.Hidden + }); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs new file mode 100644 index 0000000000..22ff2b98ce --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs @@ -0,0 +1,165 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.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.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneDrawableRoom : OsuTestScene + { + [Cached] + protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + private readonly Bindable selectedRoom = new Bindable(); + + [Test] + public void TestMultipleStatuses() + { + AddStep("create rooms", () => + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.9f), + Spacing = new Vector2(10), + Children = new Drawable[] + { + createLoungeRoom(new Room + { + Name = { Value = "Multiplayer room" }, + Status = { Value = new RoomStatusOpen() }, + EndDate = { Value = DateTimeOffset.Now.AddDays(1) }, + Type = { Value = MatchType.HeadToHead }, + Playlist = + { + new PlaylistItem + { + Beatmap = + { + Value = new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + StarDifficulty = 2.5 + } + }.BeatmapInfo, + } + } + } + }), + createLoungeRoom(new Room + { + Name = { Value = "Playlist room with multiple beatmaps" }, + Status = { Value = new RoomStatusPlaying() }, + EndDate = { Value = DateTimeOffset.Now.AddDays(1) }, + Playlist = + { + new PlaylistItem + { + Beatmap = + { + Value = new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + StarDifficulty = 2.5 + } + }.BeatmapInfo, + } + }, + new PlaylistItem + { + Beatmap = + { + Value = new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + StarDifficulty = 4.5 + } + }.BeatmapInfo, + } + } + } + }), + createLoungeRoom(new Room + { + Name = { Value = "Finished room" }, + Status = { Value = new RoomStatusEnded() }, + EndDate = { Value = DateTimeOffset.Now }, + }), + createLoungeRoom(new Room + { + Name = { Value = "Spotlight room" }, + Status = { Value = new RoomStatusOpen() }, + Category = { Value = RoomCategory.Spotlight }, + }), + } + }; + }); + } + + [Test] + public void TestEnableAndDisablePassword() + { + DrawableRoom drawableRoom = null; + Room room = null; + + AddStep("create room", () => Child = drawableRoom = createLoungeRoom(room = new Room + { + Name = { Value = "Room with password" }, + Status = { Value = new RoomStatusOpen() }, + Type = { Value = MatchType.HeadToHead }, + })); + + AddUntilStep("wait for panel load", () => drawableRoom.ChildrenOfType().Any()); + + 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)); + } + + private DrawableRoom createLoungeRoom(Room room) + { + room.Host.Value ??= new User { Username = "peppy", Id = 2 }; + + if (room.RecentParticipants.Count == 0) + { + room.RecentParticipants.AddRange(Enumerable.Range(0, 20).Select(i => new User + { + Id = i, + Username = $"User {i}" + })); + } + + return new DrawableLoungeRoom(room) + { + MatchingFilter = true, + SelectedRoom = { BindTarget = selectedRoom } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs new file mode 100644 index 0000000000..982dfc5cd9 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs @@ -0,0 +1,154 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Users; +using osu.Game.Users.Drawables; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneDrawableRoomParticipantsList : OnlinePlayTestScene + { + private DrawableRoomParticipantsList list; + + [SetUp] + public new void Setup() => Schedule(() => + { + SelectedRoom.Value = new Room + { + Name = { Value = "test room" }, + Host = + { + Value = new User + { + Id = 2, + Username = "peppy", + } + } + }; + + Child = list = new DrawableRoomParticipantsList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + NumberOfCircles = 4 + }; + }); + + [Test] + public void TestCircleCountNearLimit() + { + AddStep("add 8 users", () => + { + for (int i = 0; i < 8; i++) + addUser(i); + }); + + AddStep("set 8 circles", () => list.NumberOfCircles = 8); + AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0); + + AddStep("add one more user", () => addUser(9)); + AddAssert("2 hidden users", () => list.ChildrenOfType().Single().Count == 2); + + AddStep("remove first user", () => removeUserAt(0)); + AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0); + + AddStep("add one more user", () => addUser(9)); + AddAssert("2 hidden users", () => list.ChildrenOfType().Single().Count == 2); + + AddStep("remove last user", () => removeUserAt(8)); + AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0); + } + + [Test] + public void TestHiddenUsersBecomeDisplayed() + { + AddStep("add 8 users", () => + { + for (int i = 0; i < 8; i++) + addUser(i); + }); + + AddStep("set 3 circles", () => list.NumberOfCircles = 3); + + for (int i = 0; i < 8; i++) + { + AddStep("remove user", () => removeUserAt(0)); + int remainingUsers = 8 - i; + + int displayedUsers = remainingUsers > 4 ? 3 : remainingUsers; + AddAssert($"{displayedUsers} avatars displayed", () => list.ChildrenOfType().Count() == displayedUsers); + } + } + + [Test] + public void TestCircleCount() + { + AddStep("add 50 users", () => + { + for (int i = 0; i < 50; i++) + addUser(i); + }); + + AddStep("set 3 circles", () => list.NumberOfCircles = 3); + AddAssert("3 users displayed", () => list.ChildrenOfType().Count() == 3); + AddAssert("48 hidden users", () => list.ChildrenOfType().Single().Count == 48); + + AddStep("set 10 circles", () => list.NumberOfCircles = 10); + AddAssert("10 users displayed", () => list.ChildrenOfType().Count() == 10); + AddAssert("41 hidden users", () => list.ChildrenOfType().Single().Count == 41); + } + + [Test] + public void TestAddAndRemoveUsers() + { + AddStep("add 50 users", () => + { + for (int i = 0; i < 50; i++) + addUser(i); + }); + + AddStep("remove from start", () => removeUserAt(0)); + AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4); + AddAssert("46 hidden users", () => list.ChildrenOfType().Single().Count == 46); + + AddStep("remove from end", () => removeUserAt(SelectedRoom.Value.RecentParticipants.Count - 1)); + AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4); + AddAssert("45 hidden users", () => list.ChildrenOfType().Single().Count == 45); + + AddRepeatStep("remove 45 users", () => removeUserAt(0), 45); + AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4); + AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0); + AddAssert("hidden users bubble hidden", () => list.ChildrenOfType().Single().Alpha < 0.5f); + + AddStep("remove another user", () => removeUserAt(0)); + AddAssert("3 circles displayed", () => list.ChildrenOfType().Count() == 3); + AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0); + + AddRepeatStep("remove the remaining two users", () => removeUserAt(0), 2); + AddAssert("1 circle displayed", () => list.ChildrenOfType().Count() == 1); + } + + private void addUser(int id) + { + SelectedRoom.Value.RecentParticipants.Add(new User + { + Id = id, + Username = $"User {id}" + }); + SelectedRoom.Value.ParticipantCount.Value++; + } + + private void removeUserAt(int index) + { + SelectedRoom.Value.RecentParticipants.RemoveAt(index); + SelectedRoom.Value.ParticipantCount.Value--; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index dfb78a235b..93bdbb79f4 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -14,7 +14,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Containers; using osu.Game.Online.Rooms; -using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -215,7 +215,7 @@ namespace osu.Game.Tests.Visual.Multiplayer assertDownloadButtonVisible(false); void assertDownloadButtonVisible(bool visible) => AddUntilStep($"download button {(visible ? "shown" : "hidden")}", - () => playlist.ChildrenOfType().Single().Alpha == (visible ? 1 : 0)); + () => playlist.ChildrenOfType().Single().Alpha == (visible ? 1 : 0)); } [Test] @@ -229,7 +229,7 @@ namespace osu.Game.Tests.Visual.Multiplayer createPlaylist(byOnlineId, byChecksum); - AddAssert("download buttons shown", () => playlist.ChildrenOfType().All(d => d.IsPresent)); + AddAssert("download buttons shown", () => playlist.ChildrenOfType().All(d => d.IsPresent)); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs new file mode 100644 index 0000000000..a3a1cacb0d --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs @@ -0,0 +1,131 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.Play; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneGameplayChatDisplay : MultiplayerTestScene + { + private GameplayChatDisplay chatDisplay; + + [Cached(typeof(ILocalUserPlayInfo))] + private ILocalUserPlayInfo localUserInfo; + + private readonly Bindable localUserPlaying = new Bindable(); + + private TextBox textBox => chatDisplay.ChildrenOfType().First(); + + public TestSceneGameplayChatDisplay() + { + var mockLocalUserInfo = new Mock(); + mockLocalUserInfo.SetupGet(i => i.IsPlaying).Returns(localUserPlaying); + + localUserInfo = mockLocalUserInfo.Object; + } + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("load chat display", () => Child = chatDisplay = new GameplayChatDisplay(SelectedRoom.Value) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + }); + + AddStep("expand", () => chatDisplay.Expanded.Value = true); + } + + [Test] + public void TestCantClickWhenPlaying() + { + setLocalUserPlaying(true); + + AddStep("attempt focus chat", () => + { + InputManager.MoveMouseTo(textBox); + InputManager.Click(MouseButton.Left); + }); + + assertChatFocused(false); + } + + [Test] + public void TestFocusDroppedWhenPlaying() + { + assertChatFocused(false); + + AddStep("focus chat", () => + { + InputManager.MoveMouseTo(textBox); + InputManager.Click(MouseButton.Left); + }); + + setLocalUserPlaying(true); + assertChatFocused(false); + + // should still stay non-focused even after entering a new break section. + setLocalUserPlaying(false); + assertChatFocused(false); + } + + [Test] + public void TestFocusOnTabKeyWhenExpanded() + { + setLocalUserPlaying(true); + + assertChatFocused(false); + AddStep("press tab", () => InputManager.Key(Key.Tab)); + assertChatFocused(true); + } + + [Test] + public void TestFocusOnTabKeyWhenNotExpanded() + { + AddStep("set not expanded", () => chatDisplay.Expanded.Value = false); + AddUntilStep("is not visible", () => !chatDisplay.IsPresent); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + assertChatFocused(true); + AddUntilStep("is visible", () => chatDisplay.IsPresent); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + assertChatFocused(false); + AddUntilStep("is not visible", () => !chatDisplay.IsPresent); + } + + [Test] + public void TestFocusToggleViaAction() + { + AddStep("set not expanded", () => chatDisplay.Expanded.Value = false); + AddUntilStep("is not visible", () => !chatDisplay.IsPresent); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + assertChatFocused(true); + AddUntilStep("is visible", () => chatDisplay.IsPresent); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + assertChatFocused(false); + AddUntilStep("is not visible", () => !chatDisplay.IsPresent); + } + + private void assertChatFocused(bool isFocused) => + AddAssert($"chat {(isFocused ? "focused" : "not focused")}", () => textBox.HasFocus == isFocused); + + private void setLocalUserPlaying(bool playing) => + AddStep($"local user {(playing ? "playing" : "not playing")}", () => localUserPlaying.Value = playing); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs deleted file mode 100644 index 471d0b6c98..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using NUnit.Framework; -using osu.Framework.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 : OnlinePlayTestScene - { - [SetUp] - public new void Setup() => Schedule(() => - { - SelectedRoom.Value = new Room(); - - Child = new RoomInfo - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 500 - }; - }); - - [Test] - public void TestNonSelectedRoom() - { - AddStep("set null room", () => SelectedRoom.Value.RoomID.Value = null); - } - - [Test] - public void TestOpenRoom() - { - AddStep("set open room", () => - { - 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 75cc687ee8..99b530c2a2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -4,20 +4,21 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Game.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Tests.Visual.OnlinePlay; -using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneLoungeRoomsContainer : OnlinePlayTestScene { - protected new BasicTestRoomManager RoomManager => (BasicTestRoomManager)base.RoomManager; + protected new TestRequestHandlingRoomManager RoomManager => (TestRequestHandlingRoomManager)base.RoomManager; private RoomsContainer container; @@ -29,7 +30,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 0.5f, - JoinRequested = joinRequested + SelectedRoom = { BindTarget = SelectedRoom } }; }); @@ -39,15 +40,12 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("add rooms", () => RoomManager.AddRooms(3)); AddAssert("has 3 rooms", () => container.Rooms.Count == 3); - AddStep("remove first room", () => RoomManager.Rooms.Remove(RoomManager.Rooms.FirstOrDefault())); + AddStep("remove first room", () => RoomManager.RemoveRoom(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().TriggerClick()); 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] @@ -66,9 +64,31 @@ 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 TestKeyboardNavigationAfterOrderChange() + { + AddStep("add rooms", () => RoomManager.AddRooms(3)); + + AddStep("reorder rooms", () => + { + var room = RoomManager.Rooms[1]; + + RoomManager.RemoveRoom(room); + RoomManager.AddOrUpdateRoom(room); + }); + + AddAssert("no selection", () => checkRoomSelected(null)); + + press(Key.Down); + AddAssert("first room selected", () => checkRoomSelected(getRoomInFlow(0))); + + press(Key.Down); + AddAssert("second room selected", () => checkRoomSelected(getRoomInFlow(1))); + + press(Key.Down); + AddAssert("third room selected", () => checkRoomSelected(getRoomInFlow(2))); } [Test] @@ -97,11 +117,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); - AddStep("filter one room", () => container.Filter(new FilterCriteria { SearchString = "1" })); + AddStep("filter one room", () => container.Filter.Value = new FilterCriteria { SearchString = "1" }); AddUntilStep("1 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 1); - AddStep("remove filter", () => container.Filter(null)); + AddStep("remove filter", () => container.Filter.Value = null); AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); } @@ -113,25 +133,25 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("add rooms", () => RoomManager.AddRooms(3, new CatchRuleset().RulesetInfo)); // Todo: What even is this case...? - AddStep("set empty filter criteria", () => container.Filter(null)); + AddStep("set empty filter criteria", () => container.Filter.Value = null); AddUntilStep("5 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 5); - AddStep("filter osu! rooms", () => container.Filter(new FilterCriteria { Ruleset = new OsuRuleset().RulesetInfo })); + AddStep("filter osu! rooms", () => container.Filter.Value = 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 })); + AddStep("filter catch rooms", () => container.Filter.Value = new FilterCriteria { Ruleset = new CatchRuleset().RulesetInfo }); AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3); } + [Test] + public void TestPasswordProtectedRooms() + { + AddStep("add rooms", () => RoomManager.AddRooms(3, withPassword: true)); + } + private bool checkRoomSelected(Room room) => SelectedRoom.Value == room; - private void joinRequested(Room room) => room.Status.Value = new JoinedRoomStatus(); - - private class JoinedRoomStatus : RoomStatus - { - public override string Message => "Joined"; - - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Yellow; - } + private Room getRoomInFlow(int index) => + (container.ChildrenOfType>().First().FlowingChildren.ElementAt(index) as DrawableRoom)?.Room; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs deleted file mode 100644 index 71ba5db481..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using NUnit.Framework; -using osu.Game.Beatmaps; -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 : OnlinePlayTestScene - { - [SetUp] - public new void Setup() => Schedule(() => - { - SelectedRoom.Value = new Room - { - Name = { Value = "A very awesome room" }, - Host = { Value = new User { Id = 2, Username = "peppy" } }, - Playlist = - { - new PlaylistItem - { - Beatmap = - { - Value = new BeatmapInfo - { - Metadata = new BeatmapMetadata - { - Title = "Title", - Artist = "Artist", - AuthorString = "Author", - }, - Version = "Version", - Ruleset = new OsuRuleset().RulesetInfo - } - }, - RequiredMods = - { - new OsuModDoubleTime(), - new OsuModNoFail(), - new OsuModRelax(), - } - } - } - }; - - Child = new Header(); - }); - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index e14df62af1..ade24b8740 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -6,9 +6,11 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; using osu.Framework.Timing; +using osu.Game.Online.Multiplayer; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play.HUD; +using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { @@ -31,7 +33,10 @@ namespace osu.Game.Tests.Visual.Multiplayer }; foreach (var (userId, _) in clocks) + { SpectatorClient.StartPlay(userId, 0); + OnlinePlayDependencies.Client.AddUser(new User { Id = userId }); + } }); AddStep("create leaderboard", () => @@ -41,7 +46,7 @@ namespace osu.Game.Tests.Visual.Multiplayer var scoreProcessor = new OsuScoreProcessor(); scoreProcessor.ApplyBeatmap(playable); - LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, clocks.Keys.ToArray()) { Expanded = { Value = true } }, Add); + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()) { Expanded = { Value = true } }, Add); }); AddUntilStep("wait for load", () => leaderboard.IsLoaded); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index b8db4067fb..bfcb55ce33 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -5,13 +5,22 @@ 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.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Rulesets.UI; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Users; +using osuTK.Graphics; namespace osu.Game.Tests.Visual.Multiplayer { @@ -20,12 +29,15 @@ namespace osu.Game.Tests.Visual.Multiplayer [Resolved] private OsuGameBase game { get; set; } + [Resolved] + private OsuConfigManager config { get; set; } + [Resolved] private BeatmapManager beatmapManager { get; set; } private MultiSpectatorScreen spectatorScreen; - private readonly List playingUserIds = new List(); + private readonly List playingUsers = new List(); private BeatmapSetInfo importedSet; private BeatmapInfo importedBeatmap; @@ -40,17 +52,18 @@ namespace osu.Game.Tests.Visual.Multiplayer } [SetUp] - public new void Setup() => Schedule(() => playingUserIds.Clear()); + public new void Setup() => Schedule(() => playingUsers.Clear()); [Test] public void TestDelayedStart() { AddStep("start players silently", () => { - Client.CurrentMatchPlayingUserIds.Add(PLAYER_1_ID); - Client.CurrentMatchPlayingUserIds.Add(PLAYER_2_ID); - playingUserIds.Add(PLAYER_1_ID); - playingUserIds.Add(PLAYER_2_ID); + OnlinePlayDependencies.Client.AddUser(new User { Id = PLAYER_1_ID }, true); + OnlinePlayDependencies.Client.AddUser(new User { Id = PLAYER_2_ID }, true); + + playingUsers.Add(new MultiplayerRoomUser(PLAYER_1_ID)); + playingUsers.Add(new MultiplayerRoomUser(PLAYER_2_ID)); }); loadSpectateScreen(false); @@ -67,7 +80,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestGeneral() { - int[] userIds = Enumerable.Range(0, 4).Select(i => PLAYER_1_ID + i).ToArray(); + int[] userIds = getPlayerIds(4); start(userIds); loadSpectateScreen(); @@ -76,6 +89,64 @@ namespace osu.Game.Tests.Visual.Multiplayer AddWaitStep("wait a bit", 20); } + [Test] + public void TestSpectatorPlayerInteractiveElementsHidden() + { + HUDVisibilityMode originalConfigValue = default; + + AddStep("get original config hud visibility", () => originalConfigValue = config.Get(OsuSetting.HUDVisibilityMode)); + AddStep("set config hud visibility to always", () => config.SetValue(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always)); + + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(false); + + AddUntilStep("wait for player loaders", () => this.ChildrenOfType().Count() == 2); + AddAssert("all player loader settings hidden", () => this.ChildrenOfType().All(l => !l.ChildrenOfType>().Any())); + + AddUntilStep("wait for players to load", () => spectatorScreen.AllPlayersLoaded); + + // components wrapped in skinnable target containers load asynchronously, potentially taking more than one frame to load. + // therefore use until step rather than direct assert to account for that. + AddUntilStep("all interactive elements removed", () => this.ChildrenOfType().All(p => + !p.ChildrenOfType().Any() && + !p.ChildrenOfType().Any() && + p.ChildrenOfType().SingleOrDefault()?.ShowHandle == false)); + + AddStep("restore config hud visibility", () => config.SetValue(OsuSetting.HUDVisibilityMode, originalConfigValue)); + } + + [Test] + public void TestTeamDisplay() + { + AddStep("start players", () => + { + var player1 = OnlinePlayDependencies.Client.AddUser(new User { Id = PLAYER_1_ID }, true); + player1.MatchState = new TeamVersusUserState + { + TeamID = 0, + }; + + var player2 = OnlinePlayDependencies.Client.AddUser(new User { Id = PLAYER_2_ID }, true); + player2.MatchState = new TeamVersusUserState + { + TeamID = 1, + }; + + SpectatorClient.StartPlay(player1.UserID, importedBeatmapId); + SpectatorClient.StartPlay(player2.UserID, importedBeatmapId); + + playingUsers.Add(player1); + playingUsers.Add(player2); + }); + + loadSpectateScreen(); + + sendFrames(PLAYER_1_ID, 1000); + sendFrames(PLAYER_2_ID, 1000); + + AddWaitStep("wait a bit", 20); + } + [Test] public void TestTimeDoesNotProgressWhileAllPlayersPaused() { @@ -137,7 +208,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); @@ -194,7 +265,7 @@ 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); checkPaused(PLAYER_1_ID, false); assertOneNotMuted(); @@ -245,6 +316,36 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType().Single().FrameStableClock.CurrentTime > 30000); } + [Test] + public void TestPlayersLeaveWhileSpectating() + { + start(getPlayerIds(4)); + sendFrames(getPlayerIds(4), 300); + + loadSpectateScreen(); + + for (int count = 3; count >= 0; count--) + { + var id = PLAYER_1_ID + count; + + end(id); + AddUntilStep($"{id} area grayed", () => getInstance(id).Colour != Color4.White); + AddUntilStep($"{id} score quit set", () => getLeaderboardScore(id).HasQuit.Value); + sendFrames(getPlayerIds(count), 300); + } + + Player player = null; + + AddStep($"get {PLAYER_1_ID} player instance", () => player = getInstance(PLAYER_1_ID).ChildrenOfType().Single()); + + start(new[] { PLAYER_1_ID }); + sendFrames(PLAYER_1_ID, 300); + + AddAssert($"{PLAYER_1_ID} player instance still same", () => getInstance(PLAYER_1_ID).ChildrenOfType().Single() == player); + AddAssert($"{PLAYER_1_ID} area still grayed", () => getInstance(PLAYER_1_ID).Colour != Color4.White); + AddAssert($"{PLAYER_1_ID} score quit still set", () => getLeaderboardScore(PLAYER_1_ID).HasQuit.Value); + } + private void loadSpectateScreen(bool waitForPlayerLoad = true) { AddStep("load screen", () => @@ -252,7 +353,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Beatmap.Value = beatmapManager.GetWorkingBeatmap(importedBeatmap); Ruleset.Value = importedBeatmap.Ruleset; - LoadScreen(spectatorScreen = new MultiSpectatorScreen(playingUserIds.ToArray())); + LoadScreen(spectatorScreen = new MultiSpectatorScreen(playingUsers.ToArray())); }); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded && (!waitForPlayerLoad || spectatorScreen.AllPlayersLoaded)); @@ -264,13 +365,32 @@ namespace osu.Game.Tests.Visual.Multiplayer { foreach (int id in userIds) { - Client.CurrentMatchPlayingUserIds.Add(id); + var user = new MultiplayerRoomUser(id) + { + User = new User { Id = id }, + }; + + OnlinePlayDependencies.Client.AddUser(user.User, true); SpectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId); - playingUserIds.Add(id); + + playingUsers.Add(user); } }); } + private void end(int userId) + { + AddStep($"end play for {userId}", () => + { + var user = playingUsers.Single(u => u.UserID == userId); + + OnlinePlayDependencies.Client.RemoveUser(user.User.AsNonNull()); + SpectatorClient.EndPlay(userId); + + playingUsers.Remove(user); + }); + } + private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count); private void sendFrames(int[] userIds, int count = 10) @@ -304,5 +424,9 @@ 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); + + private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType().Single(s => s.User?.Id == userId); + + private int[] getPlayerIds(int count) => Enumerable.Range(PLAYER_1_ID, count).ToArray(); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index c93640e7b5..0b70703870 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -2,15 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; 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.Input; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -20,9 +25,13 @@ 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.Match.Components; +using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK.Input; @@ -39,10 +48,10 @@ namespace osu.Game.Tests.Visual.Multiplayer private TestMultiplayer multiplayerScreen; private TestMultiplayerClient client; - public TestSceneMultiplayer() - { - loadMultiplayer(); - } + private TestRequestHandlingMultiplayerRoomManager roomManager => multiplayerScreen.RoomManager; + + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -51,18 +60,333 @@ 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(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); + AddUntilStep("wait for lounge to load", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + } + + [Test] + public void TestEmpty() + { + // used to test the flow of multiplayer from visual tests. + AddStep("empty step", () => { }); + } + + [Test] + public void TestLobbyEvents() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddRepeatStep("random stuff happens", performRandomAction, 30); + + // ensure we have a handful of players so the ready-up sounds good :9 + AddRepeatStep("player joins", addRandomPlayer, 5); + + // all ready + AddUntilStep("all players ready", () => + { + var nextUnready = client.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); + if (nextUnready != null) + client.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready); + + return client.Room?.Users.All(u => u.State == MultiplayerUserState.Ready) == true; + }); + + AddStep("unready all players at once", () => + { + Debug.Assert(client.Room != null); + + foreach (var u in client.Room.Users) client.ChangeUserState(u.UserID, MultiplayerUserState.Idle); + }); + + AddStep("ready all players at once", () => + { + Debug.Assert(client.Room != null); + + foreach (var u in client.Room.Users) client.ChangeUserState(u.UserID, MultiplayerUserState.Ready); + }); + } + + private void addRandomPlayer() + { + int randomUser = RNG.Next(200000, 500000); + client.AddUser(new User { Id = randomUser, Username = $"user {randomUser}" }); + } + + private void removeLastUser() + { + User lastUser = client.Room?.Users.Last().User; + + if (lastUser == null || lastUser == client.LocalUser?.User) + return; + + client.RemoveUser(lastUser); + } + + private void kickLastUser() + { + User lastUser = client.Room?.Users.Last().User; + + if (lastUser == null || lastUser == client.LocalUser?.User) + return; + + client.KickUser(lastUser.Id); + } + + private void markNextPlayerReady() + { + var nextUnready = client.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); + if (nextUnready != null) + client.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready); + } + + private void markNextPlayerIdle() + { + var nextUnready = client.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Ready); + if (nextUnready != null) + client.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Idle); + } + + private void performRandomAction() + { + int eventToPerform = RNG.Next(1, 6); + + switch (eventToPerform) + { + case 1: + addRandomPlayer(); + break; + + case 2: + removeLastUser(); + break; + + case 3: + kickLastUser(); + break; + + case 4: + markNextPlayerReady(); + break; + + case 5: + markNextPlayerIdle(); + break; + } + } + + [Test] + public void TestCreateRoomViaKeyboard() + { + // create room dialog + AddStep("Press new document", () => InputManager.Keys(PlatformAction.DocumentNew)); + AddUntilStep("wait for settings", () => InputManager.ChildrenOfType().FirstOrDefault() != null); + + // edit playlist item + AddStep("Press select", () => InputManager.Key(Key.Enter)); + AddUntilStep("wait for song select", () => InputManager.ChildrenOfType().FirstOrDefault() != null); + + // select beatmap + AddStep("Press select", () => InputManager.Key(Key.Enter)); + AddUntilStep("wait for return to screen", () => InputManager.ChildrenOfType().FirstOrDefault() == null); + + // create room + AddStep("Press select", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddUntilStep("wait for join", () => client.Room != null); + } + + [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() + { + AddStep("create room", () => + { + roomManager.AddServerSideRoom(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", () => this.ChildrenOfType().Single().UpdateFilter()); + AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); + + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("join room and immediately exit select", () => + { + InputManager.Key(Key.Enter); + Schedule(() => Stack.CurrentScreen.Exit()); + }); + } + + [Test] + public void TestJoinRoomWithoutPassword() + { + AddStep("create room", () => + { + roomManager.AddServerSideRoom(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", () => this.ChildrenOfType().Single().UpdateFilter()); + AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); + + 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", () => + { + roomManager.AddServerSideRoom(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", () => this.ChildrenOfType().Single().UpdateFilter()); + AddUntilStep("wait for room", () => this.ChildrenOfType().Any()); + + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("join room", () => InputManager.Key(Key.Enter)); + + DrawableLoungeRoom.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().TriggerClick()); + + 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" }, @@ -85,8 +409,6 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestLocalPlayDoesNotStartWhileSpectatingWithNoBeatmap() { - loadMultiplayer(); - createRoom(() => new Room { Name = { Value = "Test Room" }, @@ -115,6 +437,8 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); + AddUntilStep("wait for spectating user state", () => client.LocalUser?.State == MultiplayerUserState.Spectating); + AddStep("start match externally", () => client.StartMatch()); AddAssert("play not started", () => multiplayerScreen.IsCurrentScreen()); @@ -123,8 +447,6 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestLocalPlayStartsWhileSpectatingWhenBeatmapBecomesAvailable() { - loadMultiplayer(); - createRoom(() => new Room { Name = { Value = "Test Room" }, @@ -153,6 +475,8 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); + AddUntilStep("wait for spectating user state", () => client.LocalUser?.State == MultiplayerUserState.Spectating); + AddStep("start match externally", () => client.StartMatch()); AddStep("restore beatmap", () => @@ -164,11 +488,29 @@ 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() { - loadMultiplayer(); - createRoom(() => new Room { Name = { Value = "Test Room" }, @@ -183,16 +525,14 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddStep("open mod overlay", () => this.ChildrenOfType().ElementAt(2).Click()); + AddStep("open mod overlay", () => this.ChildrenOfType().Single().TriggerClick()); AddStep("invoke on back button", () => multiplayerScreen.OnBackButton()); - AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + 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 @@ -208,12 +548,44 @@ namespace osu.Game.Tests.Visual.Multiplayer } } + [Test] + public void TestGameplayFlow() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddRepeatStep("click spectate button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }, 2); + + AddUntilStep("wait for player", () => Stack.CurrentScreen is Player); + + // Gameplay runs in real-time, so we need to incrementally check if gameplay has finished in order to not time out. + for (double i = 1000; i < TestResources.QUICK_BEATMAP_LENGTH; i += 1000) + { + var time = i; + AddUntilStep($"wait for time > {i}", () => this.ChildrenOfType().SingleOrDefault()?.GameplayClock.CurrentTime > time); + } + + AddUntilStep("wait for results", () => Stack.CurrentScreen is ResultsScreen); + } + private void createRoom(Func room) { - AddStep("open room", () => - { - multiplayerScreen.OpenNewRoom(room()); - }); + AddUntilStep("wait for lounge", () => multiplayerScreen.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); + AddStep("open room", () => multiplayerScreen.ChildrenOfType().Single().Open(room())); AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddWaitStep("wait for transition", 2); @@ -227,26 +599,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => client.Room != null); } - private void loadMultiplayer() - { - 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); - } - /// /// Used for the sole purpose of adding as a resolvable dependency. /// @@ -263,9 +615,9 @@ namespace osu.Game.Tests.Visual.Multiplayer private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer { - public new TestMultiplayerRoomManager RoomManager { get; private set; } + public new TestRequestHandlingMultiplayerRoomManager RoomManager { get; private set; } - protected override RoomManager CreateRoomManager() => RoomManager = new TestMultiplayerRoomManager(); + protected override RoomManager CreateRoomManager() => RoomManager = new TestRequestHandlingMultiplayerRoomManager(); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index 0e368b59dd..3317ddc767 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -12,6 +12,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Osu.Scoring; @@ -20,6 +21,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.Spectator; +using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { @@ -50,22 +52,23 @@ namespace osu.Game.Tests.Visual.Multiplayer OsuScoreProcessor scoreProcessor; Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + var multiplayerUsers = new List(); foreach (var user in users) + { SpectatorClient.StartPlay(user, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); - - // Todo: This is REALLY bad. - Client.CurrentMatchPlayingUserIds.AddRange(users); + multiplayerUsers.Add(OnlinePlayDependencies.Client.AddUser(new User { Id = user }, true)); + } Children = new Drawable[] { scoreProcessor = new OsuScoreProcessor(), }; - scoreProcessor.ApplyBeatmap(playable); + scoreProcessor.ApplyBeatmap(playableBeatmap); - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, users.ToArray()) + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, multiplayerUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs new file mode 100644 index 0000000000..dfaf2f1dc3 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.Play.HUD; +using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Tests.Visual.Spectator; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerGameplayLeaderboardTeams : MultiplayerTestScene + { + private static IEnumerable users => Enumerable.Range(0, 16); + + public new TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient SpectatorClient => + (TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient)OnlinePlayDependencies?.SpectatorClient; + + protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); + + protected class TestDependencies : MultiplayerTestSceneDependencies + { + protected override TestSpectatorClient CreateSpectatorClient() => new TestSceneMultiplayerGameplayLeaderboard.TestMultiplayerSpectatorClient(); + } + + private MultiplayerGameplayLeaderboard leaderboard; + private GameplayMatchScoreDisplay gameplayScoreDisplay; + + protected override Room CreateRoom() + { + var room = base.CreateRoom(); + room.Type.Value = MatchType.TeamVersus; + return room; + } + + [SetUpSteps] + public override void SetUpSteps() + { + AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = LookupCache.GetUserAsync(1).Result); + + AddStep("create leaderboard", () => + { + leaderboard?.Expire(); + + OsuScoreProcessor scoreProcessor; + Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); + + var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + var multiplayerUsers = new List(); + + foreach (var user in users) + { + SpectatorClient.StartPlay(user, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); + var roomUser = OnlinePlayDependencies.Client.AddUser(new User { Id = user }, true); + + roomUser.MatchState = new TeamVersusUserState + { + TeamID = RNG.Next(0, 2) + }; + + multiplayerUsers.Add(roomUser); + } + + Children = new Drawable[] + { + scoreProcessor = new OsuScoreProcessor(), + }; + + scoreProcessor.ApplyBeatmap(playableBeatmap); + + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, multiplayerUsers.ToArray()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, gameplayLeaderboard => + { + LoadComponentAsync(new MatchScoreDisplay + { + Team1Score = { BindTarget = leaderboard.TeamScores[0] }, + Team2Score = { BindTarget = leaderboard.TeamScores[1] } + }, Add); + + LoadComponentAsync(gameplayScoreDisplay = new GameplayMatchScoreDisplay + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Team1Score = { BindTarget = leaderboard.TeamScores[0] }, + Team2Score = { BindTarget = leaderboard.TeamScores[1] } + }, Add); + + Add(gameplayLeaderboard); + }); + }); + + AddUntilStep("wait for load", () => leaderboard.IsLoaded); + AddUntilStep("wait for user population", () => Client.CurrentMatchPlayingUserIds.Count > 0); + } + + [Test] + public void TestScoreUpdates() + { + AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 100); + AddToggleStep("switch compact mode", expanded => + { + leaderboard.Expanded.Value = expanded; + gameplayScoreDisplay.Expanded.Value = expanded; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs new file mode 100644 index 0000000000..b7da31a2b5 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs @@ -0,0 +1,157 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.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.Multiplayer; +using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerLoungeSubScreen : OnlinePlayTestScene + { + protected new TestRequestHandlingRoomManager RoomManager => (TestRequestHandlingRoomManager)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 TestPopoverHidesOnBackButton() + { + 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()); + + AddAssert("textbox has focus", () => InputManager.FocusedDrawable is OsuPasswordTextBox); + + AddStep("hit escape", () => InputManager.Key(Key.Escape)); + AddAssert("textbox lost focus", () => InputManager.FocusedDrawable is SearchTextBox); + + AddStep("hit escape", () => InputManager.Key(Key.Escape)); + AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + } + + [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 TestJoinRoomWithIncorrectPasswordViaButton() + { + DrawableLoungeRoom.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 = "wrong"); + AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().TriggerClick()); + + AddAssert("room not joined", () => loungeScreen.IsCurrentScreen()); + AddUntilStep("password prompt still visible", () => passwordEntryPopover.State.Value == Visibility.Visible); + AddAssert("textbox still focused", () => InputManager.FocusedDrawable is OsuPasswordTextBox); + } + + [Test] + public void TestJoinRoomWithIncorrectPasswordViaEnter() + { + DrawableLoungeRoom.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 = "wrong"); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddAssert("room not joined", () => loungeScreen.IsCurrentScreen()); + AddUntilStep("password prompt still visible", () => passwordEntryPopover.State.Value == Visibility.Visible); + AddAssert("textbox still focused", () => InputManager.FocusedDrawable is OsuPasswordTextBox); + } + + [Test] + public void TestJoinRoomWithCorrectPassword() + { + DrawableLoungeRoom.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().TriggerClick()); + + AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); + AddAssert("room join password correct", () => lastJoinedPassword == "password"); + } + + [Test] + public void TestJoinRoomWithPasswordViaKeyboardOnly() + { + DrawableLoungeRoom.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 enter", () => InputManager.Key(Key.Enter)); + + 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 4e08ffef17..44a8d7b439 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; @@ -15,11 +16,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { SelectedRoom.Value = new Room(); - Child = new MultiplayerMatchFooter + Child = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Height = 50 + RelativeSizeAxes = Axes.X, + Height = 50, + Child = new MultiplayerMatchFooter() }; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 8bcb9cebbc..0d6b428cce 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -15,6 +15,7 @@ using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.Rooms; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; @@ -89,7 +90,7 @@ namespace osu.Game.Tests.Visual.Multiplayer SelectedMods.SetDefault(); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestMultiplayerMatchSongSelect())); + AddStep("create song select", () => LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value))); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); } @@ -168,6 +169,11 @@ namespace osu.Game.Tests.Visual.Multiplayer public new Bindable> FreeMods => base.FreeMods; public new BeatmapCarousel Carousel => base.Carousel; + + public TestMultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) + : base(room, beatmap, ruleset) + { + } } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 955be6ca21..21364fe154 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -59,23 +59,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for load", () => screen.IsCurrentScreen()); } - [Test] - public void TestSettingValidity() - { - AddAssert("create button not enabled", () => !this.ChildrenOfType().Single().Enabled.Value); - - AddStep("set playlist", () => - { - SelectedRoom.Value.Playlist.Add(new PlaylistItem - { - Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, - Ruleset = { Value = new OsuRuleset().RulesetInfo }, - }); - }); - - AddAssert("create button enabled", () => this.ChildrenOfType().Single().Enabled.Value); - } - [Test] public void TestCreatedRoom() { @@ -97,6 +80,23 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => Client.Room != null); } + [Test] + public void TestSettingValidity() + { + AddAssert("create button not enabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("set playlist", () => + { + SelectedRoom.Value.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + }); + }); + + AddAssert("create button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + } + [Test] public void TestStartMatchWhileSpectating() { @@ -129,6 +129,8 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); + AddUntilStep("wait for spectating user state", () => Client.LocalUser?.State == MultiplayerUserState.Spectating); + AddUntilStep("wait for ready button to be enabled", () => this.ChildrenOfType().Single().ChildrenOfType().Single().Enabled.Value); AddStep("click ready button", () => diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 6526f7eea7..c4ebc13245 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; @@ -48,9 +49,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 1); - AddStep("add non-resolvable user", () => Client.AddNullUser(-3)); + AddStep("add non-resolvable user", () => Client.AddNullUser()); + AddAssert("null user added", () => Client.Room.AsNonNull().Users.Count(u => u.User == null) == 1); AddUntilStep("two unique panels", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 2); + + AddStep("kick null user", () => this.ChildrenOfType().Single(p => p.User.User == null) + .ChildrenOfType().Single().TriggerClick()); + + AddAssert("null user kicked", () => Client.Room.AsNonNull().Users.Count == 1); } [Test] @@ -155,6 +162,42 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("second user crown visible", () => this.ChildrenOfType().ElementAt(1).ChildrenOfType().First().Alpha == 1); } + [Test] + public void TestKickButtonOnlyPresentWhenHost() + { + AddStep("add user", () => Client.AddUser(new User + { + Id = 3, + Username = "Second", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + })); + + AddUntilStep("kick buttons visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 1); + + AddStep("make second user host", () => Client.TransferHost(3)); + + AddUntilStep("kick buttons not visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 0); + + AddStep("make local user host again", () => Client.TransferHost(API.LocalUser.Value.Id)); + + AddUntilStep("kick buttons visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 1); + } + + [Test] + public void TestKickButtonKicks() + { + AddStep("add user", () => Client.AddUser(new User + { + Id = 3, + Username = "Second", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + })); + + AddStep("kick second user", () => this.ChildrenOfType().Single(d => d.IsPresent).TriggerClick()); + + AddAssert("second user kicked", () => Client.Room?.Users.Single().UserID == API.LocalUser.Value.Id); + } + [Test] public void TestManyUsers() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs new file mode 100644 index 0000000000..a1543f99e1 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.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.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Multiplayer; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerPlayer : MultiplayerTestScene + { + private MultiplayerPlayer player; + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("set beatmap", () => + { + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + }); + + AddStep("initialise gameplay", () => + { + Stack.Push(player = new MultiplayerPlayer(Client.APIRoom, Client.CurrentMatchPlayingItem.Value, Client.Room?.Users.ToArray())); + }); + } + + [Test] + public void TestGameplay() + { + AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs index 0e036e8868..820b403a10 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs @@ -113,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)] @@ -132,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(); } @@ -206,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()); @@ -218,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/TestSceneMultiplayerResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs new file mode 100644 index 0000000000..5032cdaec7 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerResults : ScreenTestScene + { + [Test] + public void TestDisplayResults() + { + MultiplayerResultsScreen screen = null; + + AddStep("show results screen", () => + { + var rulesetInfo = new OsuRuleset().RulesetInfo; + var beatmapInfo = CreateBeatmap(rulesetInfo).BeatmapInfo; + + var score = new ScoreInfo + { + Rank = ScoreRank.B, + TotalScore = 987654, + Accuracy = 0.8, + MaxCombo = 500, + Combo = 250, + BeatmapInfo = beatmapInfo, + User = new User { Username = "Test user" }, + Date = DateTimeOffset.Now, + OnlineScoreID = 12345, + Ruleset = rulesetInfo, + }; + + PlaylistItem playlistItem = new PlaylistItem + { + BeatmapID = beatmapInfo.ID, + }; + + Stack.Push(screen = new MultiplayerResultsScreen(score, 1, playlistItem)); + }); + + AddUntilStep("wait for loaded", () => screen.IsLoaded); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs deleted file mode 100644 index b17427a30b..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using NUnit.Framework; -using osu.Framework.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 : MultiplayerTestScene - { - protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); - - public TestSceneMultiplayerRoomManager() - : base(false) - { - } - - [Test] - public void TestPollsInitially() - { - AddStep("create room manager with a few rooms", () => - { - 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); - } - - [Test] - public void TestRoomsClearedOnDisconnection() - { - AddStep("create room manager with a few rooms", () => - { - RoomManager.CreateRoom(createRoom()); - RoomManager.PartRoom(); - RoomManager.CreateRoom(createRoom()); - RoomManager.PartRoom(); - }); - - AddStep("disconnect", () => Client.Disconnect()); - - AddAssert("rooms cleared", () => ((RoomManager)RoomManager).Rooms.Count == 0); - AddAssert("initial rooms not received", () => !RoomManager.InitialRoomsReceived.Value); - } - - [Test] - public void TestRoomsPolledOnReconnect() - { - AddStep("create room manager with a few rooms", () => - { - RoomManager.CreateRoom(createRoom()); - RoomManager.PartRoom(); - RoomManager.CreateRoom(createRoom()); - RoomManager.PartRoom(); - }); - - 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); - } - - [Test] - public void TestRoomsNotPolledWhenJoined() - { - AddStep("create room manager with a room", () => - { - RoomManager.CreateRoom(createRoom()); - RoomManager.ClearRooms(); - }); - - AddAssert("manager not polled for rooms", () => ((RoomManager)RoomManager).Rooms.Count == 0); - AddAssert("initial rooms not received", () => !RoomManager.InitialRoomsReceived.Value); - } - - [Test] - public void TestMultiplayerRoomJoinedWhenCreated() - { - AddStep("create room manager with a room", () => - { - RoomManager.CreateRoom(createRoom()); - }); - - AddUntilStep("multiplayer room joined", () => Client.Room != null); - } - - [Test] - public void TestMultiplayerRoomPartedWhenAPIRoomParted() - { - AddStep("create room manager with a room", () => - { - RoomManager.CreateRoom(createRoom()); - RoomManager.PartRoom(); - }); - - AddAssert("multiplayer room parted", () => Client.Room == null); - } - - [Test] - public void TestMultiplayerRoomJoinedWhenAPIRoomJoined() - { - AddStep("create room manager with a room", () => - { - var r = createRoom(); - RoomManager.CreateRoom(r); - RoomManager.PartRoom(); - RoomManager.JoinRoom(r); - }); - - AddUntilStep("multiplayer room joined", () => Client.Room != null); - } - - private Room createRoom(Action initFunc = null) - { - var room = new Room - { - Name = - { - Value = "test room" - }, - Playlist = - { - new PlaylistItem - { - Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, - Ruleset = { Value = Ruleset.Value } - } - } - }; - - initFunc?.Invoke(room); - return room; - } - - private class TestDependencies : MultiplayerTestSceneDependencies - { - public TestDependencies() - { - // 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 4966dfbe50..3d08d5da9e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -122,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)] @@ -174,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/TestSceneMultiplayerTeamResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs new file mode 100644 index 0000000000..99d5fd46e9 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerTeamResults : ScreenTestScene + { + [TestCase(7483253, 1048576)] + [TestCase(1048576, 7483253)] + [TestCase(1048576, 1048576)] + public void TestDisplayTeamResults(int team1Score, int team2Score) + { + MultiplayerResultsScreen screen = null; + + AddStep("show results screen", () => + { + var rulesetInfo = new OsuRuleset().RulesetInfo; + var beatmapInfo = CreateBeatmap(rulesetInfo).BeatmapInfo; + + var score = new ScoreInfo + { + Rank = ScoreRank.B, + TotalScore = 987654, + Accuracy = 0.8, + MaxCombo = 500, + Combo = 250, + BeatmapInfo = beatmapInfo, + User = new User { Username = "Test user" }, + Date = DateTimeOffset.Now, + OnlineScoreID = 12345, + Ruleset = rulesetInfo, + }; + + PlaylistItem playlistItem = new PlaylistItem + { + BeatmapID = beatmapInfo.ID, + }; + + SortedDictionary teamScores = new SortedDictionary + { + { 0, new BindableInt(team1Score) }, + { 1, new BindableInt(team2Score) } + }; + + Stack.Push(screen = new MultiplayerTeamResultsScreen(score, 1, playlistItem, teamScores)); + }); + + AddUntilStep("wait for loaded", () => screen.IsLoaded); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index e4bf9b36ed..ba30315663 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -93,7 +93,7 @@ namespace osu.Game.Tests.Visual.Multiplayer SelectedMods.Value = Array.Empty(); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect())); + AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(SelectedRoom.Value))); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); } @@ -183,6 +183,11 @@ namespace osu.Game.Tests.Visual.Multiplayer private class TestPlaylistsSongSelect : PlaylistsSongSelect { public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; + + public TestPlaylistsSongSelect(Room room) + : base(room) + { + } } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs new file mode 100644 index 0000000000..9e03743e8d --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs @@ -0,0 +1,95 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneRankRangePill : MultiplayerTestScene + { + [SetUp] + public new void Setup() => Schedule(() => + { + Child = new RankRangePill + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + + [Test] + public void TestSingleUser() + { + AddStep("add user", () => + { + Client.AddUser(new User + { + Id = 2, + Statistics = { GlobalRank = 1234 } + }); + + // Remove the local user so only the one above is displayed. + Client.RemoveUser(API.LocalUser.Value); + }); + } + + [Test] + public void TestMultipleUsers() + { + AddStep("add users", () => + { + Client.AddUser(new User + { + Id = 2, + Statistics = { GlobalRank = 1234 } + }); + + Client.AddUser(new User + { + Id = 3, + Statistics = { GlobalRank = 3333 } + }); + + Client.AddUser(new User + { + Id = 4, + Statistics = { GlobalRank = 4321 } + }); + + // Remove the local user so only the ones above are displayed. + Client.RemoveUser(API.LocalUser.Value); + }); + } + + [TestCase(1, 10)] + [TestCase(10, 100)] + [TestCase(100, 1000)] + [TestCase(1000, 10000)] + [TestCase(10000, 100000)] + [TestCase(100000, 1000000)] + [TestCase(1000000, 10000000)] + public void TestRange(int min, int max) + { + AddStep("add users", () => + { + Client.AddUser(new User + { + Id = 2, + Statistics = { GlobalRank = min } + }); + + Client.AddUser(new User + { + Id = 3, + Statistics = { GlobalRank = max } + }); + + // Remove the local user so only the ones above are displayed. + Client.RemoveUser(API.LocalUser.Value); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs deleted file mode 100644 index cec40635f3..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; -using osu.Game.Screens.OnlinePlay.Lounge.Components; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestSceneRoomStatus : OsuTestScene - { - public TestSceneRoomStatus() - { - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Children = new Drawable[] - { - 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 }, - } - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs new file mode 100644 index 0000000000..80217a7726 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs @@ -0,0 +1,192 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens; +using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; +using osu.Game.Tests.Resources; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneTeamVersus : ScreenTestScene + { + private BeatmapManager beatmaps; + private RulesetStore rulesets; + private BeatmapSetInfo importedSet; + + private DependenciesScreen dependenciesScreen; + private TestMultiplayer multiplayerScreen; + private TestMultiplayerClient client; + + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); + + [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)); + } + + public override void SetUpSteps() + { + 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 screen", () => Stack.CurrentScreen is DependenciesScreen); + AddUntilStep("wait for dependencies to start load", () => dependenciesScreen.LoadState > LoadState.NotLoaded); + AddUntilStep("wait for dependencies to load", () => dependenciesScreen.IsLoaded); + + AddStep("load multiplayer", () => LoadScreen(multiplayerScreen)); + AddUntilStep("wait for multiplayer to load", () => multiplayerScreen.IsLoaded); + AddUntilStep("wait for lounge to load", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + } + + [Test] + public void TestCreateWithType() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Type = { Value = MatchType.TeamVersus }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddAssert("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus); + AddAssert("user state arrived", () => client.Room?.Users.FirstOrDefault()?.MatchState is TeamVersusUserState); + } + + [Test] + public void TestChangeTeamsViaButton() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Type = { Value = MatchType.TeamVersus }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddAssert("user on team 0", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); + + AddStep("press button", () => + { + InputManager.MoveMouseTo(multiplayerScreen.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("user on team 1", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1); + + AddStep("press button", () => InputManager.Click(MouseButton.Left)); + AddAssert("user on team 0", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); + } + + [Test] + public void TestChangeTypeViaMatchSettings() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddAssert("room type is head to head", () => client.Room?.Settings.MatchType == MatchType.HeadToHead); + + AddStep("change to team vs", () => client.ChangeSettings(matchType: MatchType.TeamVersus)); + + AddAssert("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus); + } + + private void createRoom(Func room) + { + AddStep("open room", () => multiplayerScreen.ChildrenOfType().Single().Open(room())); + + AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddWaitStep("wait for transition", 2); + + AddStep("create room", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for join", () => client.Room != null); + } + + /// + /// Used for the sole purpose of adding as a resolvable dependency. + /// + private class DependenciesScreen : OsuScreen + { + [Cached(typeof(MultiplayerClient))] + public readonly TestMultiplayerClient Client; + + public DependenciesScreen(TestMultiplayerClient client) + { + Client = client; + } + } + + private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer + { + public new TestRequestHandlingMultiplayerRoomManager RoomManager { get; private set; } + + protected override RoomManager CreateRoomManager() => RoomManager = new TestRequestHandlingMultiplayerRoomManager(); + } + } +} diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs similarity index 63% rename from osu.Game.Tests/Visual/TestSceneOsuGame.cs rename to osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs index 4e5e8517a4..2706ff5ceb 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs @@ -6,8 +6,7 @@ using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Configuration; using osu.Framework.Graphics.Textures; using osu.Framework.Platform; using osu.Game.Audio; @@ -27,12 +26,11 @@ using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Skinning; using osu.Game.Utils; -using osuTK.Graphics; -namespace osu.Game.Tests.Visual +namespace osu.Game.Tests.Visual.Navigation { [TestFixture] - public class TestSceneOsuGame : OsuTestScene + public class TestSceneOsuGame : OsuGameTestScene { private IReadOnlyList requiredGameDependencies => new[] { @@ -74,7 +72,6 @@ namespace osu.Game.Tests.Visual typeof(FileStore), typeof(ScoreManager), typeof(BeatmapManager), - typeof(SettingsStore), typeof(RulesetConfigCache), typeof(OsuColour), typeof(IBindable), @@ -83,34 +80,61 @@ namespace osu.Game.Tests.Visual typeof(PreviewTrackManager), }; - [BackgroundDependencyLoader] - private void load(GameHost host, OsuGameBase gameBase) + [Resolved] + private OsuGameBase gameBase { get; set; } + + [Resolved] + private GameHost host { get; set; } + + [Test] + public void TestNullRulesetHandled() { - OsuGame game = new OsuGame(); - game.SetHost(host); + RulesetInfo ruleset = null; - Children = new Drawable[] + AddStep("store current ruleset", () => ruleset = Ruleset.Value); + AddStep("set global ruleset to null value", () => Ruleset.Value = null); + + AddAssert("ruleset still valid", () => Ruleset.Value.Available); + AddAssert("ruleset unchanged", () => ReferenceEquals(Ruleset.Value, ruleset)); + } + + [Test] + public void TestSwitchThreadExecutionMode() + { + AddStep("Change thread mode to multi threaded", () => { Game.Dependencies.Get().SetValue(FrameworkSetting.ExecutionMode, ExecutionMode.MultiThreaded); }); + AddStep("Change thread mode to single thread", () => { Game.Dependencies.Get().SetValue(FrameworkSetting.ExecutionMode, ExecutionMode.SingleThread); }); + } + + [Test] + public void TestUnavailableRulesetHandled() + { + RulesetInfo ruleset = null; + + AddStep("store current ruleset", () => ruleset = Ruleset.Value); + AddStep("set global ruleset to invalid value", () => Ruleset.Value = new RulesetInfo { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - }, - game - }; + Name = "unavailable", + Available = false, + }); - AddUntilStep("wait for load", () => game.IsLoaded); + AddAssert("ruleset still valid", () => Ruleset.Value.Available); + AddAssert("ruleset unchanged", () => ReferenceEquals(Ruleset.Value, ruleset)); + } + [Test] + public void TestAvailableDependencies() + { AddAssert("check OsuGame DI members", () => { foreach (var type in requiredGameDependencies) { - if (game.Dependencies.Get(type) == null) + if (Game.Dependencies.Get(type) == null) throw new InvalidOperationException($"{type} has not been cached"); } return true; }); + AddAssert("check OsuGameBase DI members", () => { foreach (var type in requiredGameBaseDependencies) diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index 92152bce18..4ec76e1e4b 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -58,8 +58,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtSongSelectFromPlayerLoader() { - AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); - PushAndConfirm(() => new TestPlaySongSelect()); + importAndWaitForSongSelect(); AddStep("Press enter", () => InputManager.Key(Key.Enter)); AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen is PlayerLoader); @@ -72,8 +71,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtMenuFromPlayerLoader() { - AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); - PushAndConfirm(() => new TestPlaySongSelect()); + importAndWaitForSongSelect(); AddStep("Press enter", () => InputManager.Key(Key.Enter)); AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen is PlayerLoader); @@ -172,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/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs index f0ddefa51d..5f5ebfccfb 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -126,7 +126,7 @@ namespace osu.Game.Tests.Visual.Navigation Ruleset = ruleset ?? new OsuRuleset().RulesetInfo }, } - }).Result; + }).Result.Value; }); AddAssert($"import {i} succeeded", () => imported != null); diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index 52b577b402..aca7ada535 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Navigation Ruleset = new OsuRuleset().RulesetInfo }, } - }).Result; + }).Result.Value; }); } @@ -130,9 +130,9 @@ namespace osu.Game.Tests.Visual.Navigation { Hash = Guid.NewGuid().ToString(), OnlineScoreID = i, - Beatmap = beatmap.Beatmaps.First(), + BeatmapInfo = beatmap.Beatmaps.First(), Ruleset = ruleset ?? new OsuRuleset().RulesetInfo - }).Result; + }).Result.Value; }); AddAssert($"import {i} succeeded", () => imported != null); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 52401d32e5..ce437e7299 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -15,7 +15,9 @@ 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.Menu; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; @@ -75,7 +77,13 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press enter", () => InputManager.Key(Key.Enter)); - AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); + AddUntilStep("wait for player", () => + { + // dismiss any notifications that may appear (ie. muted notification). + clickMouseInCentre(); + return (player = Game.ScreenStack.CurrentScreen as Player) != null; + }); + AddAssert("retry count is 0", () => player.RestartCount == 0); AddStep("attempt to retry", () => player.ChildrenOfType().First().Action()); @@ -102,7 +110,14 @@ namespace osu.Game.Tests.Visual.Navigation 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); + + AddUntilStep("wait for player", () => + { + // dismiss any notifications that may appear (ie. muted notification). + clickMouseInCentre(); + return (player = Game.ScreenStack.CurrentScreen as Player) != null; + }); + 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); @@ -129,7 +144,13 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press enter", () => InputManager.Key(Key.Enter)); - AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); + AddUntilStep("wait for player", () => + { + // dismiss any notifications that may appear (ie. muted notification). + clickMouseInCentre(); + return (player = Game.ScreenStack.CurrentScreen as Player) != null; + }); + AddUntilStep("wait for fail", () => player.HasFailed); AddUntilStep("wait for track stop", () => !Game.MusicController.IsPlaying); @@ -316,11 +337,98 @@ namespace osu.Game.Tests.Visual.Navigation PushAndConfirm(() => multiplayer = new TestMultiplayer()); - AddStep("open room", () => multiplayer.OpenNewRoom()); + AddUntilStep("wait for lounge", () => multiplayer.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); + AddStep("open room", () => multiplayer.ChildrenOfType().Single().Open()); AddStep("press back button", () => Game.ChildrenOfType().First().Action()); AddWaitStep("wait two frames", 2); } + [Test] + public void TestOverlayClosing() + { + // use now playing overlay for "overlay -> background" drag case + // since most overlays use a scroll container that absorbs on mouse down + NowPlayingOverlay nowPlayingOverlay = null; + + AddUntilStep("Wait for now playing load", () => (nowPlayingOverlay = Game.ChildrenOfType().FirstOrDefault()) != null); + + AddStep("enter menu", () => InputManager.Key(Key.Enter)); + AddUntilStep("toolbar displayed", () => Game.Toolbar.State.Value == Visibility.Visible); + + AddStep("open now playing", () => InputManager.Key(Key.F6)); + AddUntilStep("now playing is visible", () => nowPlayingOverlay.State.Value == Visibility.Visible); + + // drag tests + + // background -> toolbar + AddStep("move cursor to background", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.BottomRight)); + AddStep("press left mouse button", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move cursor to toolbar", () => InputManager.MoveMouseTo(Game.Toolbar.ScreenSpaceDrawQuad.Centre)); + AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("now playing is hidden", () => nowPlayingOverlay.State.Value == Visibility.Hidden); + + AddStep("press now playing hotkey", () => InputManager.Key(Key.F6)); + + // toolbar -> background + AddStep("press left mouse button", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move cursor to background", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.BottomRight)); + AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("now playing is still visible", () => nowPlayingOverlay.State.Value == Visibility.Visible); + + // background -> overlay + AddStep("press left mouse button", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move cursor to now playing overlay", () => InputManager.MoveMouseTo(nowPlayingOverlay.ScreenSpaceDrawQuad.Centre)); + AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("now playing is still visible", () => nowPlayingOverlay.State.Value == Visibility.Visible); + + // overlay -> background + AddStep("press left mouse button", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move cursor to background", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.BottomRight)); + AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("now playing is still visible", () => nowPlayingOverlay.State.Value == Visibility.Visible); + + // background -> background + AddStep("press left mouse button", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move cursor to left", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.BottomLeft)); + AddStep("release left mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("now playing is hidden", () => nowPlayingOverlay.State.Value == Visibility.Hidden); + + AddStep("press now playing hotkey", () => InputManager.Key(Key.F6)); + + // click tests + + // toolbar + AddStep("move cursor to toolbar", () => InputManager.MoveMouseTo(Game.Toolbar.ScreenSpaceDrawQuad.Centre)); + AddStep("click left mouse button", () => InputManager.Click(MouseButton.Left)); + AddAssert("now playing is still visible", () => nowPlayingOverlay.State.Value == Visibility.Visible); + + // background + AddStep("move cursor to background", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.BottomRight)); + AddStep("click left mouse button", () => InputManager.Click(MouseButton.Left)); + AddAssert("now playing is hidden", () => nowPlayingOverlay.State.Value == Visibility.Hidden); + } + + [Test] + public void TestExitGameFromSongSelect() + { + PushAndConfirm(() => new TestPlaySongSelect()); + exitViaEscapeAndConfirm(); + + pushEscape(); // returns to osu! logo + + AddStep("Hold escape", () => InputManager.PressKey(Key.Escape)); + AddUntilStep("Wait for intro", () => Game.ScreenStack.CurrentScreen is IntroScreen); + AddStep("Release escape", () => InputManager.ReleaseKey(Key.Escape)); + AddUntilStep("Wait for game exit", () => Game.ScreenStack.CurrentScreen == null); + AddStep("test dispose doesn't crash", () => Game.Dispose()); + } + + private void clickMouseInCentre() + { + InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Left); + } + private void pushEscape() => AddStep("Press escape", () => InputManager.Key(Key.Escape)); @@ -353,10 +461,10 @@ namespace osu.Game.Tests.Visual.Navigation public TestMultiplayer() { - Client = new TestMultiplayerClient((TestMultiplayerRoomManager)RoomManager); + Client = new TestMultiplayerClient((TestRequestHandlingMultiplayerRoomManager)RoomManager); } - protected override RoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); + protected override RoomManager CreateRoomManager() => new TestRequestHandlingMultiplayerRoomManager(); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs new file mode 100644 index 0000000000..cb7c334656 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestSceneStartupImport : OsuGameTestScene + { + private string importFilename; + + protected override TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API, new[] { importFilename }); + + public override void SetUpSteps() + { + AddStep("Prepare import beatmap", () => importFilename = TestResources.GetTestBeatmapForImport()); + + base.SetUpSteps(); + } + + [Test] + public void TestImportCreatedNotification() + { + AddUntilStep("Import notification was presented", () => Game.Notifications.ChildrenOfType().Count() == 1); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs index c1c968e862..7e3d8290be 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs @@ -9,9 +9,12 @@ namespace osu.Game.Tests.Visual.Navigation { public class TestSettingsMigration : OsuGameTestScene { - public override void RecycleLocalStorage() + public override void RecycleLocalStorage(bool isDisposing) { - base.RecycleLocalStorage(); + base.RecycleLocalStorage(isDisposing); + + if (isDisposing) + return; using (var config = new OsuConfigManager(LocalStorage)) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs index 437c5b07c9..06cc613c17 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("show manually", () => accountCreation.Show()); AddUntilStep("overlay is visible", () => accountCreation.State.Value == Visibility.Visible); - AddStep("click button", () => accountCreation.ChildrenOfType().Single().Click()); + AddStep("click button", () => accountCreation.ChildrenOfType().Single().TriggerClick()); AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType().SingleOrDefault()?.IsPresent == true); AddStep("log back in", () => API.Login("dummy", "password")); diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index 5bfb676f81..963809ebe1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs @@ -5,10 +5,10 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -17,10 +17,11 @@ using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Users; +using osuTK.Input; namespace osu.Game.Tests.Visual.Online { - public class TestSceneBeatmapListingOverlay : OsuTestScene + public class TestSceneBeatmapListingOverlay : OsuManualInputManagerTestScene { private readonly List setsForResponse = new List(); @@ -28,27 +29,33 @@ namespace osu.Game.Tests.Visual.Online private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType().Single(); - [BackgroundDependencyLoader] - private void load() + [SetUpSteps] + public void SetUpSteps() { - Child = overlay = new BeatmapListingOverlay { State = { Value = Visibility.Visible } }; - - ((DummyAPIAccess)API).HandleRequest = req => + AddStep("setup overlay", () => { - if (!(req is SearchBeatmapSetsRequest searchBeatmapSetsRequest)) return false; - - searchBeatmapSetsRequest.TriggerSuccess(new SearchBeatmapSetsResponse - { - BeatmapSets = setsForResponse, - }); - - return true; - }; + Child = overlay = new BeatmapListingOverlay { State = { Value = Visibility.Visible } }; + setsForResponse.Clear(); + }); AddStep("initialize dummy", () => { + var api = (DummyAPIAccess)API; + + api.HandleRequest = req => + { + if (!(req is SearchBeatmapSetsRequest searchBeatmapSetsRequest)) return false; + + searchBeatmapSetsRequest.TriggerSuccess(new SearchBeatmapSetsResponse + { + BeatmapSets = setsForResponse, + }); + + return true; + }; + // non-supporter user - ((DummyAPIAccess)API).LocalUser.Value = new User + api.LocalUser.Value = new User { Username = "TestBot", Id = API.LocalUser.Value.Id + 1, @@ -56,6 +63,51 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestHideViaBack() + { + AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); + AddStep("hide", () => InputManager.Key(Key.Escape)); + AddUntilStep("is hidden", () => overlay.State.Value == Visibility.Hidden); + } + + [Test] + public void TestHideViaBackWithSearch() + { + AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); + + AddStep("search something", () => overlay.ChildrenOfType().First().Text = "search"); + + AddStep("kill search", () => InputManager.Key(Key.Escape)); + + AddAssert("search textbox empty", () => string.IsNullOrEmpty(overlay.ChildrenOfType().First().Text)); + AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); + + AddStep("hide", () => InputManager.Key(Key.Escape)); + AddUntilStep("is hidden", () => overlay.State.Value == Visibility.Hidden); + } + + [Test] + public void TestHideViaBackWithScrolledSearch() + { + AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); + + AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet, 100).ToArray())); + + AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); + + AddStep("scroll to bottom", () => overlay.ChildrenOfType().First().ScrollToEnd()); + + AddStep("kill search", () => InputManager.Key(Key.Escape)); + + AddUntilStep("search textbox empty", () => string.IsNullOrEmpty(overlay.ChildrenOfType().First().Text)); + AddUntilStep("is scrolled to top", () => overlay.ChildrenOfType().First().Current == 0); + AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); + + AddStep("hide", () => InputManager.Key(Key.Escape)); + AddUntilStep("is hidden", () => overlay.State.Value == Visibility.Hidden); + } + [Test] public void TestNoBeatmapsPlaceholder() { @@ -63,7 +115,7 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); - AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any()); + AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); AddStep("fetch for 0 beatmaps", () => fetchFor()); AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); @@ -193,13 +245,15 @@ namespace osu.Game.Tests.Visual.Online noPlaceholderShown(); } + private static int searchCount; + private void fetchFor(params BeatmapSetInfo[] beatmaps) { setsForResponse.Clear(); setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b))); // trigger arbitrary change for fetching. - searchControl.Query.TriggerChange(); + searchControl.Query.Value = $"search {searchCount++}"; } private void setRankAchievedFilter(ScoreRank[] ranks) @@ -229,8 +283,8 @@ namespace osu.Game.Tests.Visual.Online private void noPlaceholderShown() { AddUntilStep("no placeholder shown", () => - !overlay.ChildrenOfType().Any() - && !overlay.ChildrenOfType().Any()); + !overlay.ChildrenOfType().Any(d => d.IsPresent) + && !overlay.ChildrenOfType().Any(d => d.IsPresent)); } private class TestAPIBeatmapSet : APIBeatmapSet diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index edc1696456..453e26ef96 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -21,6 +21,8 @@ namespace osu.Game.Tests.Visual.Online protected override bool UseOnlineAPI => true; + private int nextBeatmapSetId = 1; + public TestSceneBeatmapSetOverlay() { Add(overlay = new TestBeatmapSetOverlay()); @@ -231,7 +233,7 @@ namespace osu.Game.Tests.Visual.Online }); }); - AddAssert("shown beatmaps of current ruleset", () => overlay.Header.HeaderContent.Picker.Difficulties.All(b => b.Beatmap.Ruleset.Equals(overlay.Header.RulesetSelector.Current.Value))); + AddAssert("shown beatmaps of current ruleset", () => overlay.Header.HeaderContent.Picker.Difficulties.All(b => b.BeatmapInfo.Ruleset.Equals(overlay.Header.RulesetSelector.Current.Value))); AddAssert("left-most beatmap selected", () => overlay.Header.HeaderContent.Picker.Difficulties.First().State == BeatmapPicker.DifficultySelectorState.Selected); } @@ -240,12 +242,23 @@ namespace osu.Game.Tests.Visual.Online { AddStep("show explicit map", () => { - var beatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet; + var beatmapSet = getBeatmapSet(); beatmapSet.OnlineInfo.HasExplicitContent = true; overlay.ShowBeatmapSet(beatmapSet); }); } + [Test] + public void TestFeaturedBeatmap() + { + AddStep("show featured map", () => + { + var beatmapSet = getBeatmapSet(); + beatmapSet.OnlineInfo.TrackId = 1; + overlay.ShowBeatmapSet(beatmapSet); + }); + } + [Test] public void TestHide() { @@ -308,6 +321,14 @@ namespace osu.Game.Tests.Visual.Online }; } + private BeatmapSetInfo getBeatmapSet() + { + var beatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet; + // Make sure the overlay is reloaded (see `BeatmapSetInfo.Equals`). + beatmapSet.OnlineBeatmapSetID = nextBeatmapSetId++; + return beatmapSet; + } + private void downloadAssert(bool shown) { AddAssert($"is download button {(shown ? "shown" : "hidden")}", () => overlay.Header.HeaderContent.DownloadButtonsVisible == shown); diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs index fd5c188b94..fe8e33f783 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs @@ -58,10 +58,10 @@ namespace osu.Game.Tests.Visual.Online var firstBeatmap = createBeatmap(); var secondBeatmap = createBeatmap(); - AddStep("set first set", () => successRate.Beatmap = firstBeatmap); + AddStep("set first set", () => successRate.BeatmapInfo = firstBeatmap); AddAssert("ratings set", () => successRate.Graph.Metrics == firstBeatmap.Metrics); - AddStep("set second set", () => successRate.Beatmap = secondBeatmap); + AddStep("set second set", () => successRate.BeatmapInfo = secondBeatmap); AddAssert("ratings set", () => successRate.Graph.Metrics == secondBeatmap.Metrics); static BeatmapInfo createBeatmap() => new BeatmapInfo @@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestOnlyFailMetrics() { - AddStep("set beatmap", () => successRate.Beatmap = new BeatmapInfo + AddStep("set beatmap", () => successRate.BeatmapInfo = new BeatmapInfo { Metrics = new BeatmapMetrics { @@ -91,7 +91,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestEmptyMetrics() { - AddStep("set beatmap", () => successRate.Beatmap = new BeatmapInfo + AddStep("set beatmap", () => successRate.BeatmapInfo = new BeatmapInfo { Metrics = new BeatmapMetrics() }); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs index 8818ac75b1..8f000afb91 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using Humanizer; using NUnit.Framework; +using osu.Framework.Testing; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -95,9 +96,11 @@ namespace osu.Game.Tests.Visual.Online AddAssert(@"no stream selected", () => changelog.Header.Streams.Current.Value == null); } - [Test] - public void ShowWithBuild() + [TestCase(false)] + [TestCase(true)] + public void ShowWithBuild(bool isSupporter) { + AddStep(@"set supporter", () => dummyAPI.LocalUser.Value.IsSupporter = isSupporter); showBuild(() => new APIChangelogBuild { Version = "2018.712.0", @@ -155,6 +158,8 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep(@"wait for streams", () => changelog.Streams?.Count > 0); AddAssert(@"correct build displayed", () => changelog.Current.Value.Version == "2018.712.0"); AddAssert(@"correct stream selected", () => changelog.Header.Streams.Current.Value.Id == 5); + AddUntilStep(@"wait for content load", () => changelog.ChildrenOfType().Any()); + AddAssert(@"supporter promo showed", () => changelog.ChildrenOfType().First().Alpha == (isSupporter ? 0 : 1)); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogSupporterPromo.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogSupporterPromo.cs new file mode 100644 index 0000000000..22220a7d9c --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogSupporterPromo.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; +using osu.Game.Overlays.Changelog; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneChangelogSupporterPromo : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + public TestSceneChangelogSupporterPromo() + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new ChangelogSupporterPromo(), + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index a1549dfbce..9562b41363 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.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 System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,6 +19,7 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Chat; using osu.Game.Overlays; +using osu.Game.Overlays.Chat; using osu.Game.Overlays.Chat.Selection; using osu.Game.Overlays.Chat.Tabs; using osu.Game.Users; @@ -40,6 +43,9 @@ namespace osu.Game.Tests.Visual.Online private Channel channel2 => channels[1]; private Channel channel3 => channels[2]; + [CanBeNull] + private Func> onGetMessages; + [Resolved] private GameHost host { get; set; } @@ -78,6 +84,8 @@ namespace osu.Game.Tests.Visual.Online { AddStep("register request handling", () => { + onGetMessages = null; + ((DummyAPIAccess)API).HandleRequest = req => { switch (req) @@ -85,6 +93,28 @@ namespace osu.Game.Tests.Visual.Online case JoinChannelRequest joinChannel: joinChannel.TriggerSuccess(); return true; + + case GetUserRequest getUser: + if (getUser.Lookup.Equals("some body", StringComparison.OrdinalIgnoreCase)) + { + getUser.TriggerSuccess(new User + { + Username = "some body", + Id = 1, + }); + } + else + { + getUser.TriggerFailure(new Exception()); + } + + return true; + + case GetMessagesRequest getMessages: + var messages = onGetMessages?.Invoke(getMessages.Channel); + if (messages != null) + getMessages.TriggerSuccess(messages); + return true; } return false; @@ -105,14 +135,37 @@ namespace osu.Game.Tests.Visual.Online } [Test] - public void TestSelectingChannelClosesSelector() + public void TestChannelSelection() { AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible); + AddStep("Setup get message response", () => onGetMessages = channel => + { + if (channel == channel1) + { + return new List + { + new Message(1) + { + ChannelId = channel1.Id, + Content = "hello from channel 1!", + Sender = new User + { + Id = 2, + Username = "test_user" + } + } + }; + } + + return null; + }); AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); AddStep("Switch to channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); AddAssert("Current channel is channel 1", () => currentChannel == channel1); + AddUntilStep("Loading spinner hidden", () => chatOverlay.ChildrenOfType().All(spinner => !spinner.IsPresent)); + AddAssert("Channel message shown", () => chatOverlay.ChildrenOfType().Count() == 1); AddAssert("Channel selector was closed", () => chatOverlay.SelectionOverlayState == Visibility.Hidden); } @@ -322,6 +375,27 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Current channel is channel 1", () => currentChannel == channel1); } + [Test] + public void TestChatCommand() + { + AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Open chat with user", () => channelManager.PostCommand("chat some body")); + AddAssert("PM channel is selected", () => + channelManager.CurrentChannel.Value.Type == ChannelType.PM && channelManager.CurrentChannel.Value.Users.Single().Username == "some body"); + + AddStep("Open chat with non-existent user", () => channelManager.PostCommand("chat nobody")); + AddAssert("Last message is error", () => channelManager.CurrentChannel.Value.Messages.Last() is ErrorMessage); + + // Make sure no unnecessary requests are made when the PM channel is already open. + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + AddStep("Unregister request handling", () => ((DummyAPIAccess)API).HandleRequest = null); + AddStep("Open chat with user", () => channelManager.PostCommand("chat some body")); + AddAssert("PM channel is selected", () => + channelManager.CurrentChannel.Value.Type == ChannelType.PM && channelManager.CurrentChannel.Value.Users.Single().Username == "some body"); + } + private void pressChannelHotkey(int number) { var channelKey = Key.Number0 + number; @@ -330,22 +404,11 @@ namespace osu.Game.Tests.Visual.Online InputManager.ReleaseKey(Key.AltLeft); } - private void pressCloseDocumentKeys() => pressKeysFor(PlatformActionType.DocumentClose); + private void pressCloseDocumentKeys() => InputManager.Keys(PlatformAction.DocumentClose); - private void pressNewTabKeys() => pressKeysFor(PlatformActionType.TabNew); + private void pressNewTabKeys() => InputManager.Keys(PlatformAction.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 pressRestoreTabKeys() => InputManager.Keys(PlatformAction.TabRestore); private void clickDrawable(Drawable d) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs index cd22bb2513..31e5a9b86c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs @@ -42,19 +42,21 @@ namespace osu.Game.Tests.Visual.Online () => commentsContainer.ChildrenOfType().Single().IsLoading); } - [Test] - public void TestSingleCommentsPage() + [TestCase(false)] + [TestCase(true)] + public void TestSingleCommentsPage(bool withPinned) { - setUpCommentsResponse(exampleComments); + setUpCommentsResponse(getExampleComments(withPinned)); AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); AddUntilStep("show more button hidden", () => commentsContainer.ChildrenOfType().Single().Alpha == 0); } - [Test] - public void TestMultipleCommentPages() + [TestCase(false)] + [TestCase(true)] + public void TestMultipleCommentPages(bool withPinned) { - var comments = exampleComments; + var comments = getExampleComments(withPinned); comments.HasMore = true; comments.TopLevelCount = 10; @@ -64,11 +66,12 @@ namespace osu.Game.Tests.Visual.Online () => commentsContainer.ChildrenOfType().Single().Alpha == 1); } - [Test] - public void TestMultipleLoads() + [TestCase(false)] + [TestCase(true)] + public void TestMultipleLoads(bool withPinned) { - var comments = exampleComments; - int topLevelCommentCount = exampleComments.Comments.Count; + var comments = getExampleComments(withPinned); + int topLevelCommentCount = comments.Comments.Count; AddStep("hide container", () => commentsContainer.Hide()); setUpCommentsResponse(comments); @@ -79,6 +82,48 @@ namespace osu.Game.Tests.Visual.Online () => commentsContainer.ChildrenOfType().Count() == topLevelCommentCount); } + [Test] + public void TestNoComment() + { + var comments = getExampleComments(); + comments.Comments.Clear(); + + setUpCommentsResponse(comments); + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddAssert("no comment shown", () => !commentsContainer.ChildrenOfType().Any()); + } + + [TestCase(false)] + [TestCase(true)] + public void TestSingleComment(bool withPinned) + { + var comment = new Comment + { + Id = 1, + Message = "This is a single comment", + LegacyName = "SingleUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 0, + Pinned = withPinned, + }; + + var bundle = new CommentBundle + { + Comments = new List { comment }, + IncludedComments = new List(), + PinnedComments = new List(), + }; + + if (withPinned) + bundle.PinnedComments.Add(comment); + + setUpCommentsResponse(bundle); + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddUntilStep("wait comment load", () => commentsContainer.ChildrenOfType().Any()); + AddAssert("only one comment shown", () => + commentsContainer.ChildrenOfType().Count(d => d.Comment.Pinned == withPinned) == 1); + } + private void setUpCommentsResponse(CommentBundle commentBundle) => AddStep("set up response", () => { @@ -92,38 +137,71 @@ namespace osu.Game.Tests.Visual.Online }; }); - private CommentBundle exampleComments => new CommentBundle + private CommentBundle getExampleComments(bool withPinned = false) { - Comments = new List + var bundle = new CommentBundle { - new Comment + Comments = new List { - Id = 1, - Message = "This is a comment", - LegacyName = "FirstUser", - CreatedAt = DateTimeOffset.Now, - VotesCount = 19, - RepliesCount = 1 + new Comment + { + Id = 1, + Message = "This is a comment", + LegacyName = "FirstUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 19, + RepliesCount = 1 + }, + new Comment + { + Id = 5, + ParentId = 1, + Message = "This is a child comment", + LegacyName = "SecondUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 4, + }, + new Comment + { + Id = 10, + Message = "This is another comment", + LegacyName = "ThirdUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 0 + }, }, - new Comment + IncludedComments = new List(), + PinnedComments = new List(), + }; + + if (withPinned) + { + var pinnedComment = new Comment { - Id = 5, - ParentId = 1, - Message = "This is a child comment", - LegacyName = "SecondUser", + Id = 15, + Message = "This is pinned comment", + LegacyName = "PinnedUser", CreatedAt = DateTimeOffset.Now, - VotesCount = 4, - }, - new Comment + VotesCount = 999, + Pinned = true, + RepliesCount = 1, + }; + + bundle.Comments.Add(pinnedComment); + bundle.PinnedComments.Add(pinnedComment); + + bundle.Comments.Add(new Comment { - Id = 10, - Message = "This is another comment", - LegacyName = "ThirdUser", + Id = 20, + Message = "Reply to pinned comment", + LegacyName = "AbandonedUser", CreatedAt = DateTimeOffset.Now, - VotesCount = 0 - }, - }, - IncludedComments = new List(), - }; + VotesCount = 0, + ParentId = 15, + }); + } + + return bundle; + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs index 30785fd163..2f11fec6d1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.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 System; using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Online.Spectator; @@ -21,51 +20,44 @@ namespace osu.Game.Tests.Visual.Online { private readonly User streamingUser = new User { Id = 2, Username = "Test user" }; - [Cached(typeof(SpectatorClient))] - private TestSpectatorClient testSpectatorClient = new TestSpectatorClient(); - + private TestSpectatorClient spectatorClient; private CurrentlyPlayingDisplay currentlyPlaying; - [Cached(typeof(UserLookupCache))] - private UserLookupCache lookupCache = new TestUserLookupCache(); - - private Container nestedContainer; - [SetUpSteps] public void SetUpSteps() { AddStep("add streaming client", () => { - nestedContainer?.Remove(testSpectatorClient); - Remove(lookupCache); + spectatorClient = new TestSpectatorClient(); + var lookupCache = new TestUserLookupCache(); Children = new Drawable[] { lookupCache, - nestedContainer = new Container + spectatorClient, + new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + CachedDependencies = new (Type, object)[] { - testSpectatorClient, - currentlyPlaying = new CurrentlyPlayingDisplay - { - RelativeSizeAxes = Axes.Both, - } + (typeof(SpectatorClient), spectatorClient), + (typeof(UserLookupCache), lookupCache) + }, + Child = currentlyPlaying = new CurrentlyPlayingDisplay + { + RelativeSizeAxes = Axes.Both, } }, }; }); - - AddStep("Reset players", () => testSpectatorClient.EndPlay(streamingUser.Id)); } [Test] public void TestBasicDisplay() { - AddStep("Add playing user", () => testSpectatorClient.StartPlay(streamingUser.Id, 0)); + AddStep("Add playing user", () => spectatorClient.StartPlay(streamingUser.Id, 0)); AddUntilStep("Panel loaded", () => currentlyPlaying.ChildrenOfType()?.FirstOrDefault()?.User.Id == 2); - AddStep("Remove playing user", () => testSpectatorClient.EndPlay(streamingUser.Id)); + AddStep("Remove playing user", () => spectatorClient.EndPlay(streamingUser.Id)); AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType().Any()); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs index fd5f306e07..722010ace2 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs @@ -99,16 +99,23 @@ namespace osu.Game.Tests.Visual.Online [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - var normal = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet; + var normal = getBeatmapSet(); normal.OnlineInfo.HasVideo = true; normal.OnlineInfo.HasStoryboard = true; var undownloadable = getUndownloadableBeatmapSet(); var manyDifficulties = getManyDifficultiesBeatmapSet(rulesets); - var explicitMap = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet; + var explicitMap = getBeatmapSet(); explicitMap.OnlineInfo.HasExplicitContent = true; + var featuredMap = getBeatmapSet(); + featuredMap.OnlineInfo.TrackId = 1; + + var explicitFeaturedMap = getBeatmapSet(); + explicitFeaturedMap.OnlineInfo.HasExplicitContent = true; + explicitFeaturedMap.OnlineInfo.TrackId = 2; + Child = new BasicScrollContainer { RelativeSizeAxes = Axes.Both, @@ -125,13 +132,19 @@ namespace osu.Game.Tests.Visual.Online new GridBeatmapPanel(undownloadable), new GridBeatmapPanel(manyDifficulties), new GridBeatmapPanel(explicitMap), + new GridBeatmapPanel(featuredMap), + new GridBeatmapPanel(explicitFeaturedMap), new ListBeatmapPanel(normal), new ListBeatmapPanel(undownloadable), new ListBeatmapPanel(manyDifficulties), - new ListBeatmapPanel(explicitMap) + new ListBeatmapPanel(explicitMap), + new ListBeatmapPanel(featuredMap), + new ListBeatmapPanel(explicitFeaturedMap) }, }, }; + + BeatmapSetInfo getBeatmapSet() => CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs b/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs new file mode 100644 index 0000000000..b26850feb2 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneDrawableComment : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private Container container; + + [SetUp] + public void SetUp() => Schedule(() => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + container = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }; + }); + + [TestCaseSource(nameof(comments))] + public void TestComment(string description, string text) + { + AddStep(description, () => + { + comment.Pinned = description == "Pinned"; + comment.Message = text; + container.Add(new DrawableComment(comment)); + }); + } + + private static readonly Comment comment = new Comment + { + Id = 1, + LegacyName = "Test User", + CreatedAt = DateTimeOffset.Now, + VotesCount = 0, + }; + + private static object[] comments = + { + new[] { "Plain", "This is plain comment" }, + new[] { "Pinned", "This is pinned comment" }, + new[] { "Link", "Please visit https://osu.ppy.sh" }, + + new[] + { + "Heading", @"# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6" + }, + + // Taken from https://github.com/ppy/osu/issues/13993#issuecomment-885994077 + new[] + { + "Problematic", @"My tablet doesn't work :( +It's a Huion 420 and it's apparently incompatible with OpenTablet Driver. The warning I get is: ""DeviceInUseException: Device is currently in use by another kernel module. To fix this issue, please follow the instructions from https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Linux-FAQ#arg umentoutofrangeexception-value-0-15"" and it repeats 4 times on the notification before logging subsequent warnings. +Checking the logs, it looks for other Huion tablets before sending the notification (e.g. + ""2021-07-23 03:52:33 [verbose]: Detect: Searching for tablet 'Huion WH1409 V2' + 20 2021-07-23 03:52:33 [error]: DeviceInUseException: Device is currently in use by another kernel module. To fix this issue, please follow the instructions from https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Linux-FAQ#arg umentoutofrangeexception-value-0-15"") +I use an Arch based installation of Linux and the tablet runs perfectly with Digimend kernel driver, with area configuration, pen pressure, etc. On osu!lazer the cursor disappears until I set it to ""Borderless"" instead of ""Fullscreen"" and even after it shows up, it goes to the bottom left corner as soon as a map starts. +I have honestly 0 idea of whats going on at this point." + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs index 78288bf6e4..994c4fce53 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs @@ -29,7 +29,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestControl() { - AddAssert("Front page selected", () => header.Current.Value == "frontpage"); + AddAssert("Front page selected", () => header.Current.Value == NewsHeader.FrontPageString); AddAssert("1 tab total", () => header.TabCount == 1); AddStep("Set article 1", () => header.SetArticle("1")); @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("2 tabs total", () => header.TabCount == 2); AddStep("Set front page", () => header.SetFrontPage()); - AddAssert("Front page selected", () => header.Current.Value == "frontpage"); + AddAssert("Front page selected", () => header.Current.Value == NewsHeader.FrontPageString); AddAssert("1 tab total", () => header.TabCount == 1); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs index 93a5b6fc59..f94c018b27 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("Show", () => overlay.Show()); AddUntilStep("Show More button is visible", () => showMoreButton?.Alpha == 1); setUpNewsResponse(responseWithNoCursor, "Set up no cursor response"); - AddStep("Click Show More", () => showMoreButton?.Click()); + AddStep("Click Show More", () => showMoreButton?.TriggerClick()); AddUntilStep("Show More button is hidden", () => showMoreButton?.Alpha == 0); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs index 64e80e9f02..366fa8a4af 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs @@ -49,7 +49,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestPlayActivity() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.SoloGame(new BeatmapInfo(), new RulesetInfo())); + AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new RulesetInfo())); AddStep("Run command", () => Add(new NowPlayingCommand())); diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs b/osu.Game.Tests/Visual/Online/TestSceneOfflineCommentsContainer.cs similarity index 70% rename from osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs rename to osu.Game.Tests/Visual/Online/TestSceneOfflineCommentsContainer.cs index 7fdf0708e0..4f7947b69c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneOfflineCommentsContainer.cs @@ -3,84 +3,52 @@ using System; using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; using osu.Game.Overlays.Comments; using osu.Game.Overlays; using osu.Framework.Allocation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Users; -using osu.Game.Graphics.UserInterface; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics; -using osuTK; using JetBrains.Annotations; -using NUnit.Framework; +using osu.Framework.Testing; namespace osu.Game.Tests.Visual.Online { - public class TestSceneCommentsPage : OsuTestScene + public class TestSceneOfflineCommentsContainer : OsuTestScene { [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - private readonly BindableBool showDeleted = new BindableBool(); - private readonly Container content; + private TestCommentsContainer comments; - private TestCommentsPage commentsPage; - - public TestSceneCommentsPage() + [SetUp] + public void SetUp() => Schedule(() => { - Add(new FillFlowContainer + Clear(); + Add(new BasicScrollContainer { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Children = new Drawable[] - { - new Container - { - AutoSizeAxes = Axes.Y, - Width = 200, - Child = new OsuCheckbox - { - Current = showDeleted, - LabelText = @"Show Deleted" - } - }, - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } - } + RelativeSizeAxes = Axes.Both, + Child = comments = new TestCommentsContainer() }); - } + }); [Test] public void TestAppendDuplicatedComment() { - AddStep("Create page", () => createPage(getCommentBundle())); - AddAssert("Dictionary length is 10", () => commentsPage?.DictionaryLength == 10); - AddStep("Append existing comment", () => commentsPage?.AppendComments(getCommentSubBundle())); - AddAssert("Dictionary length is 10", () => commentsPage?.DictionaryLength == 10); + AddStep("Add comment bundle", () => comments.ShowComments(getCommentBundle())); + AddUntilStep("Dictionary length is 10", () => comments.DictionaryLength == 10); + AddStep("Append existing comment", () => comments.AppendComments(getCommentSubBundle())); + AddAssert("Dictionary length is 10", () => comments.DictionaryLength == 10); } [Test] - public void TestEmptyBundle() + public void TestLocalCommentBundle() { - AddStep("Create page", () => createPage(getEmptyCommentBundle())); - AddAssert("Dictionary length is 0", () => commentsPage?.DictionaryLength == 0); - } - - private void createPage(CommentBundle commentBundle) - { - commentsPage = null; - content.Clear(); - content.Add(commentsPage = new TestCommentsPage(commentBundle) - { - ShowDeleted = { BindTarget = showDeleted } - }); + AddStep("Add comment bundle", () => comments.ShowComments(getCommentBundle())); + AddStep("Add empty comment bundle", () => comments.ShowComments(getEmptyCommentBundle())); } private CommentBundle getEmptyCommentBundle() => new CommentBundle @@ -181,6 +149,7 @@ namespace osu.Game.Tests.Visual.Online } }, IncludedComments = new List(), + PinnedComments = new List(), UserVotes = new List { 5 @@ -193,6 +162,7 @@ namespace osu.Game.Tests.Visual.Online Username = "Good_Admin" } }, + Total = 10 }; private CommentBundle getCommentSubBundle() => new CommentBundle @@ -209,18 +179,21 @@ namespace osu.Game.Tests.Visual.Online }, }, IncludedComments = new List(), + PinnedComments = new List(), }; - private class TestCommentsPage : CommentsPage + private class TestCommentsContainer : CommentsContainer { - public TestCommentsPage(CommentBundle commentBundle) - : base(commentBundle) - { - } - public new void AppendComments([NotNull] CommentBundle bundle) => base.AppendComments(bundle); public int DictionaryLength => CommentDictionary.Count; + + public void ShowComments(CommentBundle bundle) + { + this.ChildrenOfType().Single().Current.Value = 0; + ClearComments(); + OnSuccess(bundle); + } } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs index aff510dd95..027f17fff4 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.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.Allocation; -using osu.Game.Overlays; using NUnit.Framework; -using osu.Game.Users; using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; using osu.Game.Overlays.Rankings; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { @@ -14,25 +17,29 @@ namespace osu.Game.Tests.Visual.Online { protected override bool UseOnlineAPI => true; - [Cached(typeof(RankingsOverlay))] - private readonly RankingsOverlay rankingsOverlay; + private TestRankingsOverlay rankingsOverlay; private readonly Bindable countryBindable = new Bindable(); private readonly Bindable scope = new Bindable(); - public TestSceneRankingsOverlay() - { - Add(rankingsOverlay = new TestRankingsOverlay - { - Country = { BindTarget = countryBindable }, - Header = { Current = { BindTarget = scope } }, - }); - } + [SetUp] + public void SetUp() => Schedule(loadRankingsOverlay); [Test] - public void TestShow() + public void TestParentRulesetDecoupledAfterInitialShow() { - AddStep("Show", rankingsOverlay.Show); + AddStep("enable global ruleset", () => Ruleset.Disabled = false); + AddStep("set global ruleset to osu!catch", () => Ruleset.Value = new CatchRuleset().RulesetInfo); + AddStep("reload rankings overlay", loadRankingsOverlay); + AddAssert("rankings ruleset set to osu!catch", () => rankingsOverlay.Header.Ruleset.Value.ShortName == CatchRuleset.SHORT_NAME); + + AddStep("set global ruleset to osu!", () => Ruleset.Value = new OsuRuleset().RulesetInfo); + AddAssert("rankings ruleset still osu!catch", () => rankingsOverlay.Header.Ruleset.Value.ShortName == CatchRuleset.SHORT_NAME); + + AddStep("disable global ruleset", () => Ruleset.Disabled = true); + AddAssert("rankings ruleset still enabled", () => rankingsOverlay.Header.Ruleset.Disabled == false); + AddStep("set rankings ruleset to osu!mania", () => rankingsOverlay.Header.Ruleset.Value = new ManiaRuleset().RulesetInfo); + AddAssert("rankings ruleset set to osu!mania", () => rankingsOverlay.Header.Ruleset.Value.ShortName == ManiaRuleset.SHORT_NAME); } [Test] @@ -50,10 +57,14 @@ namespace osu.Game.Tests.Visual.Online AddStep("Show US", () => rankingsOverlay.ShowCountry(us_country)); } - [Test] - public void TestHide() + private void loadRankingsOverlay() { - AddStep("Hide", rankingsOverlay.Hide); + Child = rankingsOverlay = new TestRankingsOverlay + { + Country = { BindTarget = countryBindable }, + Header = { Current = { BindTarget = scope } }, + State = { Value = Visibility.Visible }, + }; } private static readonly Country us_country = new Country diff --git a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs index 18ac415126..d7fa5a1f6d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs @@ -32,19 +32,19 @@ namespace osu.Game.Tests.Visual.Online } }); - AddStep("click button", () => button.Click()); + AddStep("click button", () => button.TriggerClick()); AddAssert("action fired once", () => fireCount == 1); AddAssert("is in loading state", () => button.IsLoading); - AddStep("click button", () => button.Click()); + AddStep("click button", () => button.TriggerClick()); AddAssert("action not fired", () => fireCount == 1); AddAssert("is in loading state", () => button.IsLoading); AddUntilStep("wait for loaded", () => !button.IsLoading); - AddStep("click button", () => button.Click()); + AddStep("click button", () => button.TriggerClick()); AddAssert("action fired twice", () => fireCount == 2); AddAssert("is in loading state", () => button.IsLoading); diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index c2e9945c99..a048ae2c54 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -130,7 +130,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("visit message is not visible", () => !evast.LastVisitMessage.IsPresent); } - private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.SoloGame(null, rulesetStore.GetRuleset(rulesetId)); + private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.InSoloGame(null, rulesetStore.GetRuleset(rulesetId)); private class TestUserListPanel : UserListPanel { diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 03d079261d..70271b0b08 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -104,6 +104,9 @@ namespace osu.Game.Tests.Visual.Online CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c4.jpg" }, api.IsLoggedIn)); + AddStep("Show ppy from username", () => profile.ShowUser(@"peppy")); + AddStep("Show flyte from username", () => profile.ShowUser(@"flyte")); + AddStep("Hide", profile.Hide); AddStep("Show without reload", profile.Show); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs index 5dca218531..513631a221 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tests.Visual.Online { PP = 1047.21, Rank = ScoreRank.SH, - Beatmap = new BeatmapInfo + BeatmapInfo = new BeatmapInfo { Metadata = new BeatmapMetadata { @@ -46,7 +46,7 @@ namespace osu.Game.Tests.Visual.Online { PP = 134.32, Rank = ScoreRank.A, - Beatmap = new BeatmapInfo + BeatmapInfo = new BeatmapInfo { Metadata = new BeatmapMetadata { @@ -68,7 +68,7 @@ namespace osu.Game.Tests.Visual.Online { PP = 96.83, Rank = ScoreRank.S, - Beatmap = new BeatmapInfo + BeatmapInfo = new BeatmapInfo { Metadata = new BeatmapMetadata { @@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual.Online var noPPScore = new ScoreInfo { Rank = ScoreRank.B, - Beatmap = new BeatmapInfo + BeatmapInfo = new BeatmapInfo { Metadata = new BeatmapMetadata { diff --git a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs index e9e826e62f..a9fed7b302 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs @@ -45,7 +45,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("Log in", logIn); AddStep("User comment", () => addVotePill(getUserComment())); AddAssert("Background is transparent", () => votePill.Background.Alpha == 0); - AddStep("Click", () => votePill.Click()); + AddStep("Click", () => votePill.TriggerClick()); AddAssert("Not loading", () => !votePill.IsLoading); } @@ -56,7 +56,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("Log in", logIn); AddStep("Random comment", () => addVotePill(getRandomComment())); AddAssert("Background is visible", () => votePill.Background.Alpha == 1); - AddStep("Click", () => votePill.Click()); + AddStep("Click", () => votePill.TriggerClick()); AddAssert("Loading", () => votePill.IsLoading); } @@ -66,7 +66,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("Hide login overlay", () => login.Hide()); AddStep("Log out", API.Logout); AddStep("Random comment", () => addVotePill(getRandomComment())); - AddStep("Click", () => votePill.Click()); + AddStep("Click", () => votePill.TriggerClick()); AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs index 863fa48ddf..08e61d19f4 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; @@ -43,7 +44,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestWikiHeader() { - AddAssert("Current is index", () => checkCurrent("index")); + AddAssert("Current is index", () => checkCurrent(WikiHeader.IndexPageString)); AddStep("Change wiki page data", () => wikiPageData.Value = new APIWikiPage { @@ -53,8 +54,8 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Current is welcome", () => checkCurrent("Welcome")); AddAssert("Check breadcrumb", checkBreadcrumb); - AddStep("Change current to index", () => header.Current.Value = "index"); - AddAssert("Current is index", () => checkCurrent("index")); + AddStep("Change current to index", () => header.Current.Value = WikiHeader.IndexPageString); + AddAssert("Current is index", () => checkCurrent(WikiHeader.IndexPageString)); AddStep("Change wiki page data", () => wikiPageData.Value = new APIWikiPage { @@ -70,7 +71,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Check breadcrumb", checkBreadcrumb); } - private bool checkCurrent(string expectedCurrent) => header.Current.Value == expectedCurrent; + private bool checkCurrent(LocalisableString expectedCurrent) => header.Current.Value == expectedCurrent; private bool checkBreadcrumb() { @@ -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/Playlists/TestScenePlaylistsFilterControl.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsFilterControl.cs deleted file mode 100644 index 40e191dd7e..0000000000 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsFilterControl.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Game.Screens.OnlinePlay.Lounge.Components; - -namespace osu.Game.Tests.Visual.Playlists -{ - public class TestScenePlaylistsFilterControl : OsuTestScene - { - public TestScenePlaylistsFilterControl() - { - Child = new PlaylistsFilterControl - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Width = 0.7f, - Height = 80, - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index b16b61c5c7..5c248163d7 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs @@ -3,48 +3,99 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Graphics.Containers; -using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.Playlists { public class TestScenePlaylistsLoungeSubScreen : OnlinePlayTestScene { - protected new BasicTestRoomManager RoomManager => (BasicTestRoomManager)base.RoomManager; + protected new TestRequestHandlingRoomManager RoomManager => (TestRequestHandlingRoomManager)base.RoomManager; - private LoungeSubScreen loungeScreen; + private TestLoungeSubScreen loungeScreen; public override void SetUpSteps() { base.SetUpSteps(); - AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen())); + AddStep("push screen", () => LoadScreen(loungeScreen = new TestLoungeSubScreen())); AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); } private RoomsContainer roomsContainer => loungeScreen.ChildrenOfType().First(); + [Test] + public void TestManyRooms() + { + AddStep("add rooms", () => RoomManager.AddRooms(500)); + } + + [Test] + public void TestScrollByDraggingRooms() + { + AddStep("reset mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddStep("add rooms", () => RoomManager.AddRooms(30)); + AddUntilStep("wait for rooms", () => roomsContainer.Rooms.Count == 30); + + AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0])); + + AddStep("move mouse to third room", () => InputManager.MoveMouseTo(roomsContainer.Rooms[2])); + AddStep("hold down", () => InputManager.PressButton(MouseButton.Left)); + AddStep("drag to top", () => InputManager.MoveMouseTo(roomsContainer.Rooms[0])); + + AddAssert("first and second room masked", () + => !checkRoomVisible(roomsContainer.Rooms[0]) && + !checkRoomVisible(roomsContainer.Rooms[1])); + } + [Test] public void TestScrollSelectedIntoView() { AddStep("add rooms", () => RoomManager.AddRooms(30)); + AddUntilStep("wait for rooms", () => roomsContainer.Rooms.Count == 30); - AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms.First())); + AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0])); - AddStep("select last room", () => roomsContainer.Rooms.Last().Action?.Invoke()); + AddStep("select last room", () => roomsContainer.Rooms[^1].TriggerClick()); - AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms.First())); - AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms.Last())); + AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms[0])); + AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms[^1])); + } + + [Test] + public void TestEnteringRoomTakesLeaseOnSelection() + { + AddStep("add rooms", () => RoomManager.AddRooms(1)); + + AddAssert("selected room is not disabled", () => !loungeScreen.SelectedRoom.Disabled); + + AddStep("select room", () => roomsContainer.Rooms[0].TriggerClick()); + AddAssert("selected room is non-null", () => loungeScreen.SelectedRoom.Value != null); + + AddStep("enter room", () => roomsContainer.Rooms[0].TriggerClick()); + + AddUntilStep("wait for match load", () => Stack.CurrentScreen is PlaylistsRoomSubScreen); + + AddAssert("selected room is non-null", () => loungeScreen.SelectedRoom.Value != null); + AddAssert("selected room is disabled", () => loungeScreen.SelectedRoom.Disabled); } private bool checkRoomVisible(DrawableRoom room) => loungeScreen.ChildrenOfType().First().ScreenSpaceDrawQuad .Contains(room.ScreenSpaceDrawQuad.Centre); + + private class TestLoungeSubScreen : PlaylistsLoungeSubScreen + { + public new Bindable SelectedRoom => base.SelectedRoom; + } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs index a320cb240f..48222e6b94 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs @@ -28,7 +28,7 @@ namespace osu.Game.Tests.Visual.Playlists { SelectedRoom.Value = new Room(); - Child = settings = new TestRoomSettings + Child = settings = new TestRoomSettings(SelectedRoom.Value) { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible } @@ -110,7 +110,7 @@ namespace osu.Game.Tests.Visual.Playlists AddUntilStep("error not displayed", () => !settings.ErrorText.IsPresent); } - private class TestRoomSettings : PlaylistsMatchSettingsOverlay + private class TestRoomSettings : PlaylistsRoomSettingsOverlay { public TriangleButton ApplyButton => ((MatchSettings)Settings).ApplyButton; @@ -118,6 +118,11 @@ namespace osu.Game.Tests.Visual.Playlists public OsuDropdown DurationField => ((MatchSettings)Settings).DurationField; public OsuSpriteText ErrorText => ((MatchSettings)Settings).ErrorText; + + public TestRoomSettings(Room room) + : base(room) + { + } } private class TestDependencies : OnlinePlayTestSceneDependencies @@ -141,6 +146,12 @@ namespace osu.Game.Tests.Visual.Playlists public IBindableList Rooms => null; + public void AddOrUpdateRoom(Room room) => throw new NotImplementedException(); + + public void RemoveRoom(Room room) => throw new NotImplementedException(); + + public void ClearRooms() => throw new NotImplementedException(); + public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) { if (CreateRequested == null) @@ -152,7 +163,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/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 61d49e4018..d948aebbbf 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -32,12 +32,14 @@ namespace osu.Game.Tests.Visual.Playlists private TestResultsScreen resultsScreen; private int currentScoreId; private bool requestComplete; + private int totalCount; [SetUp] public void Setup() => Schedule(() => { currentScoreId = 0; requestComplete = false; + totalCount = 0; bindHandler(); }); @@ -53,7 +55,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); } @@ -62,7 +63,6 @@ namespace osu.Game.Tests.Visual.Playlists public void TestShowNullUserScore() { createResults(); - waitForDisplay(); AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } @@ -79,7 +79,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddAssert("more than 1 panel displayed", () => this.ChildrenOfType().Count() > 1); AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); @@ -91,7 +90,6 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind delayed handler", () => bindHandler(true)); createResults(); - waitForDisplay(); AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } @@ -100,7 +98,6 @@ namespace osu.Game.Tests.Visual.Playlists public void TestFetchWhenScrolledToTheRight() { createResults(); - waitForDisplay(); AddStep("bind delayed handler", () => bindHandler(true)); @@ -131,7 +128,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddStep("bind delayed handler", () => bindHandler(true)); @@ -160,11 +156,16 @@ namespace osu.Game.Tests.Visual.Playlists Ruleset = { Value = new OsuRuleset().RulesetInfo } })); }); + + waitForDisplay(); } private void waitForDisplay() { - AddUntilStep("wait for request to complete", () => requestComplete); + AddUntilStep("wait for load to complete", () => + requestComplete + && resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount + && resultsScreen.ScorePanelList.AllPanelsVisible); AddWaitStep("wait for display", 5); } @@ -200,6 +201,7 @@ namespace osu.Game.Tests.Visual.Playlists triggerFail(s); else triggerSuccess(s, createUserResponse(userScore)); + break; case IndexPlaylistScoresRequest i: @@ -245,6 +247,8 @@ namespace osu.Game.Tests.Visual.Playlists } }; + totalCount++; + for (int i = 1; i <= scores_per_result; i++) { multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore @@ -282,6 +286,8 @@ namespace osu.Game.Tests.Visual.Playlists }, Statistics = userScore.Statistics }); + + totalCount += 2; } addCursor(multiplayerUserScore.ScoresAround.Lower); @@ -322,6 +328,8 @@ namespace osu.Game.Tests.Visual.Playlists { HitResult.Great, 300 } } }); + + totalCount++; } addCursor(result); diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs index f2bfb80beb..deb9a22184 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -11,7 +11,6 @@ using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -19,7 +18,6 @@ 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 @@ -36,18 +34,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)); - - ((DummyAPIAccess)API).HandleRequest = req => - { - switch (req) - { - case CreateRoomScoreRequest createRoomScoreRequest: - createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); - return true; - } - - return false; - }; } [SetUpSteps] @@ -66,7 +52,7 @@ namespace osu.Game.Tests.Visual.Playlists { SelectedRoom.Value.RoomID.Value = 1; SelectedRoom.Value.Name.Value = "my awesome room"; - SelectedRoom.Value.Host.Value = new User { Id = 2, Username = "peppy" }; + SelectedRoom.Value.Host.Value = API.LocalUser.Value; SelectedRoom.Value.RecentParticipants.Add(SelectedRoom.Value.Host.Value); SelectedRoom.Value.EndDate.Value = DateTimeOffset.Now.AddMinutes(5); SelectedRoom.Value.Playlist.Add(new PlaylistItem @@ -76,7 +62,7 @@ namespace osu.Game.Tests.Visual.Playlists }); }); - AddStep("start match", () => match.ChildrenOfType().First().Click()); + AddStep("start match", () => match.ChildrenOfType().First().TriggerClick()); AddUntilStep("player loader loaded", () => Stack.CurrentScreen is PlayerLoader); } @@ -86,7 +72,7 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("set room properties", () => { SelectedRoom.Value.Name.Value = "my awesome room"; - SelectedRoom.Value.Host.Value = new User { Id = 2, Username = "peppy" }; + SelectedRoom.Value.Host.Value = API.LocalUser.Value; SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, @@ -96,7 +82,7 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("move mouse to create button", () => { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); }); AddStep("click", () => InputManager.Click(MouseButton.Left)); @@ -131,13 +117,13 @@ namespace osu.Game.Tests.Visual.Playlists { beatmap.BeatmapInfo.BaseDifficulty.CircleSize = 1; - importedSet = manager.Import(beatmap.BeatmapInfo.BeatmapSet).Result; + importedSet = manager.Import(beatmap.BeatmapInfo.BeatmapSet).Result.Value; }); AddStep("load room", () => { SelectedRoom.Value.Name.Value = "my awesome room"; - SelectedRoom.Value.Host.Value = new User { Id = 2, Username = "peppy" }; + SelectedRoom.Value.Host.Value = API.LocalUser.Value; SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = importedSet.Beatmaps[0] }, @@ -147,15 +133,15 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("create room", () => { - InputManager.MoveMouseTo(match.ChildrenOfType().Single()); + InputManager.MoveMouseTo(match.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); - AddAssert("match has altered beatmap", () => match.Beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize == 1); + AddAssert("match has altered beatmap", () => match.Beatmap.Value.Beatmap.Difficulty.CircleSize == 1); AddStep("re-import original beatmap", () => manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait()); - AddAssert("match has original beatmap", () => match.Beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize != 1); + AddAssert("match has original beatmap", () => match.Beatmap.Value.Beatmap.Difficulty.CircleSize != 1); } private class TestPlaylistsRoomSubScreen : PlaylistsRoomSubScreen diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs index a5e2f02f31..df8500fab2 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs @@ -71,7 +71,7 @@ namespace osu.Game.Tests.Visual.Ranking Id = 2, Username = "peppy", }, - Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, + BeatmapInfo = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, TotalScore = 2845370, Accuracy = accuracy, diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs index 76cfe75b59..acacdf8644 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs @@ -29,6 +29,12 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("show example score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo))); } + [Test] + public void TestExcessMods() + { + AddStep("show excess mods score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo, true))); + } + private void showPanel(WorkingBeatmap workingBeatmap, ScoreInfo score) { Child = new ContractedPanelMiddleContentContainer(workingBeatmap, score); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 591095252f..899f351a2a 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -12,6 +12,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -34,7 +35,18 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("show example score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo) { - Beatmap = createTestBeatmap(author) + BeatmapInfo = createTestBeatmap(author) + })); + } + + [Test] + public void TestExcessMods() + { + var author = new User { Username = "mapper_name" }; + + AddStep("show excess mods score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo, true) + { + BeatmapInfo = createTestBeatmap(author) })); AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Current.Value == "mapper_name")); @@ -45,14 +57,38 @@ namespace osu.Game.Tests.Visual.Ranking { AddStep("show example score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo) { - Beatmap = createTestBeatmap(null) + BeatmapInfo = createTestBeatmap(null) })); AddAssert("mapped by text not present", () => this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text.ToString(), "mapped", "by"))); + + AddAssert("play time displayed", () => this.ChildrenOfType().Any()); } - private void showPanel(ScoreInfo score) => Child = new ExpandedPanelMiddleContentContainer(score); + [Test] + public void TestWithDefaultDate() + { + AddStep("show autoplay score", () => + { + var ruleset = new OsuRuleset(); + + var mods = new Mod[] { ruleset.GetAutoplayMod() }; + var beatmap = createTestBeatmap(null); + + showPanel(new TestScoreInfo(ruleset.RulesetInfo) + { + Mods = mods, + BeatmapInfo = beatmap, + Date = default, + }); + }); + + AddAssert("play time not displayed", () => !this.ChildrenOfType().Any()); + } + + private void showPanel(ScoreInfo score) => + Child = new ExpandedPanelMiddleContentContainer(score); private BeatmapInfo createTestBeatmap(User author) { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index ba6b6bd529..8d5d0ba8c7 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -99,7 +99,7 @@ namespace osu.Game.Tests.Visual.Ranking TestResultsScreen screen = null; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); - AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddStep("click expanded panel", () => { @@ -138,7 +138,7 @@ namespace osu.Game.Tests.Visual.Ranking TestResultsScreen screen = null; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); - AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddStep("click expanded panel", () => { @@ -177,7 +177,7 @@ namespace osu.Game.Tests.Visual.Ranking TestResultsScreen screen = null; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); - AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); ScorePanel expandedPanel = null; ScorePanel contractedPanel = null; @@ -223,6 +223,7 @@ namespace osu.Game.Tests.Visual.Ranking TestResultsScreen screen = null; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddAssert("download button is disabled", () => !screen.ChildrenOfType().Last().Enabled.Value); @@ -336,8 +337,8 @@ namespace osu.Game.Tests.Visual.Ranking public UnrankedSoloResultsScreen(ScoreInfo score) : base(score, true) { - Score.Beatmap.OnlineBeatmapID = 0; - Score.Beatmap.Status = BeatmapSetOnlineStatus.Pending; + Score.BeatmapInfo.OnlineBeatmapID = 0; + Score.BeatmapInfo.Status = BeatmapSetOnlineStatus.Pending; } protected override void LoadComplete() diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs index e65dcb19b1..b7b7407428 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs @@ -159,6 +159,9 @@ namespace osu.Game.Tests.Visual.Ranking var firstScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); var secondScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + firstScore.User.Username = "A"; + secondScore.User.Username = "B"; + createListStep(() => new ScorePanelList()); AddStep("add scores and select first", () => @@ -168,6 +171,8 @@ namespace osu.Game.Tests.Visual.Ranking list.SelectedScore.Value = firstScore; }); + AddUntilStep("wait for load", () => list.AllPanelsVisible); + assertScoreState(firstScore, true); assertScoreState(secondScore, false); @@ -182,6 +187,89 @@ namespace osu.Game.Tests.Visual.Ranking assertExpandedPanelCentred(); } + [Test] + public void TestAddScoreImmediately() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => + { + var newList = new ScorePanelList { SelectedScore = { Value = score } }; + newList.AddScore(score); + return newList; + }); + + assertScoreState(score, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestKeyboardNavigation() + { + var lowestScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { MaxCombo = 100 }; + var middleScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { MaxCombo = 200 }; + var highestScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { MaxCombo = 300 }; + + createListStep(() => new ScorePanelList()); + + AddStep("add scores and select middle", () => + { + // order of addition purposefully scrambled. + list.AddScore(middleScore); + list.AddScore(lowestScore); + list.AddScore(highestScore); + list.SelectedScore.Value = middleScore; + }); + + AddUntilStep("wait for all scores to be visible", () => list.ChildrenOfType().All(t => t.IsPresent)); + + assertScoreState(highestScore, false); + assertScoreState(middleScore, true); + assertScoreState(lowestScore, false); + + AddStep("press left", () => InputManager.Key(Key.Left)); + + assertScoreState(highestScore, true); + assertScoreState(middleScore, false); + assertScoreState(lowestScore, false); + assertExpandedPanelCentred(); + + AddStep("press left at start of list", () => InputManager.Key(Key.Left)); + + assertScoreState(highestScore, true); + assertScoreState(middleScore, false); + assertScoreState(lowestScore, false); + assertExpandedPanelCentred(); + + AddStep("press right", () => InputManager.Key(Key.Right)); + + assertScoreState(highestScore, false); + assertScoreState(middleScore, true); + assertScoreState(lowestScore, false); + assertExpandedPanelCentred(); + + AddStep("press right again", () => InputManager.Key(Key.Right)); + + assertScoreState(highestScore, false); + assertScoreState(middleScore, false); + assertScoreState(lowestScore, true); + assertExpandedPanelCentred(); + + AddStep("press right at end of list", () => InputManager.Key(Key.Right)); + + assertScoreState(highestScore, false); + assertScoreState(middleScore, false); + assertScoreState(lowestScore, true); + assertExpandedPanelCentred(); + + AddStep("press left", () => InputManager.Key(Key.Left)); + + assertScoreState(highestScore, false); + assertScoreState(middleScore, true); + assertScoreState(lowestScore, false); + assertExpandedPanelCentred(); + } + private void createListStep(Func creationFunc) { AddStep("create list", () => Child = list = creationFunc().With(d => diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs deleted file mode 100644 index 566452249f..0000000000 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using NUnit.Framework; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Utils; -using osu.Game.Beatmaps; -using osu.Game.Screens.Ranking.Expanded; -using osuTK; - -namespace osu.Game.Tests.Visual.Ranking -{ - public class TestSceneStarRatingDisplay : OsuTestScene - { - [Test] - public void TestDisplay() - { - AddStep("load displays", () => Child = new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - ChildrenEnumerable = new[] - { - 1.23, - 2.34, - 3.45, - 4.56, - 5.67, - 6.78, - 10.11, - }.Select(starRating => new StarRatingDisplay(new StarDifficulty(starRating, 0)) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }) - }); - } - - [Test] - public void TestChangingStarRatingDisplay() - { - StarRatingDisplay starRating = null; - - AddStep("load display", () => Child = starRating = new StarRatingDisplay(new StarDifficulty(5.55, 1)) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(3f), - }); - - AddRepeatStep("set random value", () => - { - starRating.Current.Value = new StarDifficulty(RNG.NextDouble(0.0, 11.0), 1); - }, 10); - - AddSliderStep("set exact stars", 0.0, 11.0, 5.55, d => - { - if (starRating != null) - starRating.Current.Value = new StarDifficulty(d, 1); - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Settings/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/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index acf9deb3cb..1effe52608 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -7,8 +7,9 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Framework.Threading; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; -using osu.Game.Overlays.KeyBinding; +using osu.Game.Overlays.Settings.Sections.Input; using osuTK.Input; namespace osu.Game.Tests.Visual.Settings @@ -32,6 +33,7 @@ namespace osu.Game.Tests.Visual.Settings [SetUpSteps] public void SetUpSteps() { + AddUntilStep("wait for load", () => panel.ChildrenOfType().Any()); AddStep("Scroll to top", () => panel.ChildrenOfType().First().ScrollToTop()); AddWaitStep("wait for scroll", 5); } @@ -160,7 +162,7 @@ namespace osu.Game.Tests.Visual.Settings { var resetButton = settingsKeyBindingRow.ChildrenOfType>().First(); - resetButton.Click(); + resetButton.TriggerClick(); }); AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); @@ -189,7 +191,7 @@ namespace osu.Game.Tests.Visual.Settings { var resetButton = panel.ChildrenOfType().First(); - resetButton.Click(); + resetButton.TriggerClick(); }); AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); @@ -229,11 +231,27 @@ namespace osu.Game.Tests.Visual.Settings AddAssert("first binding selected", () => multiBindingRow.ChildrenOfType().First().IsBinding); } + [Test] + public void TestFilteringHidesResetSectionButtons() + { + SearchTextBox searchTextBox = null; + + AddStep("add any search term", () => + { + searchTextBox = panel.ChildrenOfType().Single(); + searchTextBox.Current.Value = "chat"; + }); + AddUntilStep("all reset section bindings buttons hidden", () => panel.ChildrenOfType().All(button => button.Alpha == 0)); + + AddStep("clear search term", () => searchTextBox.Current.Value = string.Empty); + AddUntilStep("all reset section bindings buttons shown", () => panel.ChildrenOfType().All(button => button.Alpha == 1)); + } + private void checkBinding(string name, string keyName) { AddAssert($"Check {name} is bound to {keyName}", () => { - var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text == name)); + var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == name)); var firstButton = firstRow.ChildrenOfType().First(); return firstButton.Text.Text == keyName; @@ -246,7 +264,7 @@ namespace osu.Game.Tests.Visual.Settings AddStep($"Scroll to {name}", () => { - var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text == name)); + var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == name)); firstButton = firstRow.ChildrenOfType().First(); panel.ChildrenOfType().First().ScrollTo(firstButton); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs index f63145f534..d9cce69ee3 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,83 @@ 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); + } + + [Test] + public void TestWarningTextVisibility() + { + SettingsNumberBox numberBox = null; + + AddStep("create settings item", () => Child = numberBox = new SettingsNumberBox()); + AddAssert("warning text not created", () => !numberBox.ChildrenOfType().Any()); + + AddStep("set warning text", () => numberBox.WarningText = "this is a warning!"); + AddAssert("warning text created", () => numberBox.ChildrenOfType().Single().Alpha == 1); + + AddStep("unset warning text", () => numberBox.WarningText = default); + AddAssert("warning text hidden", () => numberBox.ChildrenOfType().Single().Alpha == 0); + + AddStep("set warning text again", () => numberBox.WarningText = "another warning!"); + AddAssert("warning text shown again", () => numberBox.ChildrenOfType().Single().Alpha == 1); } } -} \ No newline at end of file +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs index 115d2fec7d..0af77b3b5a 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Settings @@ -11,27 +12,39 @@ namespace osu.Game.Tests.Visual.Settings [TestFixture] public class TestSceneSettingsPanel : OsuTestScene { - private readonly SettingsPanel settings; - private readonly DialogOverlay dialogOverlay; + private SettingsPanel settings; + private DialogOverlay dialogOverlay; - public TestSceneSettingsPanel() + [SetUpSteps] + public void SetUpSteps() { - settings = new SettingsOverlay + AddStep("create settings", () => { - State = { Value = Visibility.Visible } - }; - Add(dialogOverlay = new DialogOverlay - { - Depth = -1 + settings?.Expire(); + + Add(settings = new SettingsOverlay + { + State = { Value = Visibility.Visible } + }); }); } + [Test] + public void ToggleVisibility() + { + AddWaitStep("wait some", 5); + AddToggleStep("toggle editor visibility", visible => settings.ToggleVisibility()); + } + [BackgroundDependencyLoader] private void load() { - Dependencies.Cache(dialogOverlay); + Add(dialogOverlay = new DialogOverlay + { + Depth = -1 + }); - Add(settings); + Dependencies.Cache(dialogOverlay); } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs index e3dae9c27e..d530e1f796 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs @@ -53,7 +53,11 @@ namespace osu.Game.Tests.Visual.Settings }; [SettingSource("Sample string", "Change something for a mod")] - public Bindable StringBindable { get; } = new Bindable(); + public Bindable StringBindable { get; } = new Bindable + { + Default = string.Empty, + Value = "Sample text" + }; } private enum TestEnum diff --git a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs index a62980addf..dc5b0e0d77 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs @@ -1,14 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Handlers.Tablet; -using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Overlays; +using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections.Input; using osuTK; @@ -17,22 +19,37 @@ namespace osu.Game.Tests.Visual.Settings [TestFixture] public class TestSceneTabletSettings : OsuTestScene { - [BackgroundDependencyLoader] - private void load(GameHost host) - { - var tabletHandler = new TestTabletHandler(); + private TestTabletHandler tabletHandler; + private TabletSettings settings; - AddRange(new Drawable[] + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create settings", () => { - new TabletSettings(tabletHandler) + tabletHandler = new TestTabletHandler(); + + Children = new Drawable[] { - RelativeSizeAxes = Axes.None, - Width = SettingsPanel.WIDTH, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - } + settings = new TabletSettings(tabletHandler) + { + RelativeSizeAxes = Axes.None, + Width = SettingsPanel.PANEL_WIDTH, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + }; }); + AddStep("set square size", () => tabletHandler.SetTabletSize(new Vector2(100, 100))); + } + + [Test] + public void TestVariousTabletSizes() + { AddStep("Test with wide tablet", () => tabletHandler.SetTabletSize(new Vector2(160, 100))); AddStep("Test with square tablet", () => tabletHandler.SetTabletSize(new Vector2(300, 300))); AddStep("Test with tall tablet", () => tabletHandler.SetTabletSize(new Vector2(100, 300))); @@ -40,6 +57,71 @@ namespace osu.Game.Tests.Visual.Settings AddStep("Test no tablet present", () => tabletHandler.SetTabletSize(Vector2.Zero)); } + [Test] + public void TestWideAspectRatioValidity() + { + AddStep("Test with wide tablet", () => tabletHandler.SetTabletSize(new Vector2(160, 100))); + + AddStep("Reset to full area", () => settings.ChildrenOfType().First().TriggerClick()); + ensureValid(); + + AddStep("rotate 10", () => tabletHandler.Rotation.Value = 10); + ensureInvalid(); + + AddStep("scale down", () => tabletHandler.AreaSize.Value *= 0.9f); + ensureInvalid(); + + AddStep("scale down", () => tabletHandler.AreaSize.Value *= 0.9f); + ensureInvalid(); + + AddStep("scale down", () => tabletHandler.AreaSize.Value *= 0.9f); + ensureValid(); + } + + [Test] + public void TestRotationValidity() + { + AddAssert("area valid", () => settings.AreaSelection.IsWithinBounds); + + AddStep("rotate 90", () => tabletHandler.Rotation.Value = 90); + ensureValid(); + + AddStep("rotate 180", () => tabletHandler.Rotation.Value = 180); + + ensureValid(); + + AddStep("rotate 270", () => tabletHandler.Rotation.Value = 270); + + ensureValid(); + + AddStep("rotate 360", () => tabletHandler.Rotation.Value = 360); + + ensureValid(); + + AddStep("rotate 0", () => tabletHandler.Rotation.Value = 0); + ensureValid(); + + AddStep("rotate 45", () => tabletHandler.Rotation.Value = 45); + ensureInvalid(); + + AddStep("rotate 0", () => tabletHandler.Rotation.Value = 0); + ensureValid(); + } + + [Test] + public void TestOffsetValidity() + { + ensureValid(); + AddStep("move right", () => tabletHandler.AreaOffset.Value = Vector2.Zero); + ensureInvalid(); + AddStep("move back", () => tabletHandler.AreaOffset.Value = tabletHandler.AreaSize.Value / 2); + ensureValid(); + } + + private void ensureValid() => AddAssert("area valid", () => settings.AreaSelection.IsWithinBounds); + + private void ensureInvalid() => AddAssert("area invalid", () => !settings.AreaSelection.IsWithinBounds); + public class TestTabletHandler : ITabletHandler { public Bindable AreaOffset { get; } = new Bindable(); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs index 40b2f66d74..4538e36c5e 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs @@ -51,7 +51,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestNoMod() { - AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + AddStep("set beatmap", () => advancedStats.BeatmapInfo = exampleBeatmapInfo); AddStep("no mods selected", () => SelectedMods.Value = Array.Empty()); @@ -65,7 +65,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestManiaFirstBarText() { - AddStep("set beatmap", () => advancedStats.Beatmap = new BeatmapInfo + AddStep("set beatmap", () => advancedStats.BeatmapInfo = new BeatmapInfo { Ruleset = rulesets.GetRuleset(3), BaseDifficulty = new BeatmapDifficulty @@ -84,12 +84,12 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestEasyMod() { - AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + AddStep("set beatmap", () => advancedStats.BeatmapInfo = exampleBeatmapInfo); AddStep("select EZ mod", () => { - var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); - SelectedMods.Value = new[] { ruleset.GetAllMods().OfType().Single() }; + var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance(); + SelectedMods.Value = new[] { ruleset.CreateMod() }; }); AddAssert("circle size bar is blue", () => barIsBlue(advancedStats.FirstValue)); @@ -101,12 +101,12 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestHardRockMod() { - AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + AddStep("set beatmap", () => advancedStats.BeatmapInfo = exampleBeatmapInfo); AddStep("select HR mod", () => { - var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); - SelectedMods.Value = new[] { ruleset.GetAllMods().OfType().Single() }; + var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance(); + SelectedMods.Value = new[] { ruleset.CreateMod() }; }); AddAssert("circle size bar is red", () => barIsRed(advancedStats.FirstValue)); @@ -118,13 +118,13 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestUnchangedDifficultyAdjustMod() { - AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + AddStep("set beatmap", () => advancedStats.BeatmapInfo = exampleBeatmapInfo); AddStep("select unchanged Difficulty Adjust mod", () => { - var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); - var difficultyAdjustMod = ruleset.GetAllMods().OfType().Single(); - difficultyAdjustMod.ReadFromDifficulty(advancedStats.Beatmap.BaseDifficulty); + var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance(); + var difficultyAdjustMod = ruleset.CreateMod(); + difficultyAdjustMod.ReadFromDifficulty(advancedStats.BeatmapInfo.BaseDifficulty); SelectedMods.Value = new[] { difficultyAdjustMod }; }); @@ -137,13 +137,13 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestChangedDifficultyAdjustMod() { - AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + AddStep("set beatmap", () => advancedStats.BeatmapInfo = exampleBeatmapInfo); AddStep("select changed Difficulty Adjust mod", () => { - var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); - var difficultyAdjustMod = ruleset.GetAllMods().OfType().Single(); - var originalDifficulty = advancedStats.Beatmap.BaseDifficulty; + var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance(); + var difficultyAdjustMod = ruleset.CreateMod(); + var originalDifficulty = advancedStats.BeatmapInfo.BaseDifficulty; difficultyAdjustMod.ReadFromDifficulty(originalDifficulty); difficultyAdjustMod.DrainRate.Value = originalDifficulty.DrainRate - 0.5f; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 78ddfa9ed2..66f15670f5 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Visual.SongSelect private readonly Stack selectedSets = new Stack(); private readonly HashSet eagerSelectedIDs = new HashSet(); - private BeatmapInfo currentSelection => carousel.SelectedBeatmap; + private BeatmapInfo currentSelection => carousel.SelectedBeatmapInfo; private const int set_count = 5; @@ -75,11 +75,11 @@ namespace osu.Game.Tests.Visual.SongSelect { for (int i = 0; i < 3; i++) { - AddStep("store selection", () => selection = carousel.SelectedBeatmap); + AddStep("store selection", () => selection = carousel.SelectedBeatmapInfo); if (isIterating) - AddUntilStep("selection changed", () => carousel.SelectedBeatmap != selection); + AddUntilStep("selection changed", () => carousel.SelectedBeatmapInfo != selection); else - AddUntilStep("selection not changed", () => carousel.SelectedBeatmap == selection); + AddUntilStep("selection not changed", () => carousel.SelectedBeatmapInfo == selection); } } } @@ -387,7 +387,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Set non-empty mode filter", () => carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1) }, false)); - AddAssert("Something is selected", () => carousel.SelectedBeatmap != null); + AddAssert("Something is selected", () => carousel.SelectedBeatmapInfo != null); } /// @@ -562,7 +562,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("filter to ruleset 0", () => carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }, false)); AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testMixed.Beatmaps[1], false)); - AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmap.RulesetID == 0); + AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmapInfo.RulesetID == 0); AddStep("remove mixed set", () => { @@ -653,7 +653,7 @@ namespace osu.Game.Tests.Visual.SongSelect carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); }); - AddAssert("selection lost", () => carousel.SelectedBeatmap == null); + AddAssert("selection lost", () => carousel.SelectedBeatmapInfo == null); AddStep("Restore different ruleset filter", () => { @@ -661,7 +661,7 @@ namespace osu.Game.Tests.Visual.SongSelect eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); }); - AddAssert("selection changed", () => carousel.SelectedBeatmap != manySets.First().Beatmaps.First()); + AddAssert("selection changed", () => carousel.SelectedBeatmapInfo != manySets.First().Beatmaps.First()); } AddAssert("Selection was random", () => eagerSelectedIDs.Count > 2); @@ -763,9 +763,9 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () => { if (diff != null) - return carousel.SelectedBeatmap == carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Skip(diff.Value - 1).First(); + return carousel.SelectedBeatmapInfo == carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Skip(diff.Value - 1).First(); - return carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Contains(carousel.SelectedBeatmap); + return carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Contains(carousel.SelectedBeatmapInfo); }); private void setSelected(int set, int diff) => @@ -800,7 +800,7 @@ namespace osu.Game.Tests.Visual.SongSelect { carousel.RandomAlgorithm.Value = RandomSelectAlgorithm.RandomPermutation; - if (!selectedSets.Any() && carousel.SelectedBeatmap != null) + if (!selectedSets.Any() && carousel.SelectedBeatmapInfo != null) selectedSets.Push(carousel.SelectedBeatmapSet); carousel.SelectNextRandom(); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs index b4544fbc85..d5b4fb9a80 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestAllMetrics() { - AddStep("all metrics", () => details.Beatmap = new BeatmapInfo + AddStep("all metrics", () => details.BeatmapInfo = new BeatmapInfo { BeatmapSet = new BeatmapSetInfo { @@ -61,7 +61,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestAllMetricsExceptSource() { - AddStep("all except source", () => details.Beatmap = new BeatmapInfo + AddStep("all except source", () => details.BeatmapInfo = new BeatmapInfo { BeatmapSet = new BeatmapSetInfo { @@ -91,7 +91,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestOnlyRatings() { - AddStep("ratings", () => details.Beatmap = new BeatmapInfo + AddStep("ratings", () => details.BeatmapInfo = new BeatmapInfo { BeatmapSet = new BeatmapSetInfo { @@ -117,7 +117,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestOnlyFailsAndRetries() { - AddStep("fails retries", () => details.Beatmap = new BeatmapInfo + AddStep("fails retries", () => details.BeatmapInfo = new BeatmapInfo { Version = "Only Retries and Fails", Metadata = new BeatmapMetadata @@ -144,7 +144,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestNoMetrics() { - AddStep("no metrics", () => details.Beatmap = new BeatmapInfo + AddStep("no metrics", () => details.BeatmapInfo = new BeatmapInfo { Version = "No Metrics", Metadata = new BeatmapMetadata @@ -166,13 +166,13 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestNullBeatmap() { - AddStep("null beatmap", () => details.Beatmap = null); + AddStep("null beatmap", () => details.BeatmapInfo = null); } [Test] public void TestOnlineMetrics() { - AddStep("online ratings/retries/fails", () => details.Beatmap = new BeatmapInfo + AddStep("online ratings/retries/fails", () => details.BeatmapInfo = new BeatmapInfo { OnlineBeatmapID = 162, }); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index a416fd4daf..9fa0eab548 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -2,14 +2,17 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Globalization; using System.Linq; using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; @@ -18,6 +21,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; using osu.Game.Screens.Select; using osuTK; @@ -65,6 +69,12 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("show", () => { infoWedge.Show(); }); + AddSliderStep("change star difficulty", 0, 11.9, 5.55, v => + { + foreach (var hasCurrentValue in infoWedge.Info.ChildrenOfType>()) + hasCurrentValue.Current.Value = new StarDifficulty(v, 0); + }); + foreach (var rulesetInfo in rulesets.AvailableRulesets) { var instance = rulesetInfo.CreateInstance(); @@ -134,6 +144,29 @@ namespace osu.Game.Tests.Visual.SongSelect selectBeatmap(createLongMetadata()); } + [Test] + public void TestBPMUpdates() + { + const float bpm = 120; + IBeatmap beatmap = createTestBeatmap(new OsuRuleset().RulesetInfo); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / bpm }); + + OsuModDoubleTime doubleTime = null; + + selectBeatmap(beatmap); + checkDisplayedBPM(bpm); + + AddStep("select DT", () => SelectedMods.Value = new[] { doubleTime = new OsuModDoubleTime() }); + checkDisplayedBPM(bpm * 1.5f); + + AddStep("change DT rate", () => doubleTime.SpeedChange.Value = 2); + checkDisplayedBPM(bpm * 2); + + void checkDisplayedBPM(float target) => + AddUntilStep($"displayed bpm is {target}", () => this.ChildrenOfType().Any( + label => label.Statistic.Name == "BPM" && label.Statistic.Content == target.ToString(CultureInfo.InvariantCulture))); + } + private void selectBeatmap([CanBeNull] IBeatmap b) { Container containerBefore = null; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 184a2e59da..13b769c80a 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.SongSelect 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)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory, Scheduler)); return dependencies; } @@ -76,7 +76,7 @@ namespace osu.Game.Tests.Visual.SongSelect beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); - leaderboard.Beatmap = beatmapInfo; + leaderboard.BeatmapInfo = beatmapInfo; }); clearScores(); @@ -186,7 +186,7 @@ namespace osu.Game.Tests.Visual.SongSelect private void checkCount(int expected) => AddUntilStep("Correct count displayed", () => leaderboard.ChildrenOfType().Count() == expected); - private static ScoreInfo[] generateSampleScores(BeatmapInfo beatmap) + private static ScoreInfo[] generateSampleScores(BeatmapInfo beatmapInfo) { return new[] { @@ -197,7 +197,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 6602580, @@ -216,7 +216,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 4608074, @@ -235,7 +235,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 1014222, @@ -254,7 +254,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 1541390, @@ -273,7 +273,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 2243452, @@ -292,7 +292,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 2705430, @@ -311,7 +311,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 7151382, @@ -330,7 +330,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 2051389, @@ -349,7 +349,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 6169483, @@ -368,7 +368,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, - Beatmap = beatmap, + BeatmapInfo = beatmapInfo, User = new User { Id = 6702666, @@ -385,7 +385,7 @@ namespace osu.Game.Tests.Visual.SongSelect private void showBeatmapWithStatus(BeatmapSetOnlineStatus status) { - leaderboard.Beatmap = new BeatmapInfo + leaderboard.BeatmapInfo = new BeatmapInfo { OnlineBeatmapID = 1113057, Status = status, diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs index 271fbde5c3..f91d3f595b 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 { @@ -91,7 +92,7 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep("setup display", () => { - var randomMods = Ruleset.Value.CreateInstance().GetAllMods().OrderBy(_ => RNG.Next()).Take(5).ToList(); + var randomMods = Ruleset.Value.CreateInstance().CreateAllMods().OrderBy(_ => RNG.Next()).Take(5).ToList(); OsuLogo logo = new OsuLogo { Scale = new Vector2(0.15f) }; @@ -142,9 +143,9 @@ namespace osu.Game.Tests.Visual.SongSelect public override async Task GetDifficultyAsync(BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo = null, IEnumerable mods = null, CancellationToken cancellationToken = default) { if (blockCalculation) - await calculationBlocker.Task; + await calculationBlocker.Task.ConfigureAwait(false); - return await base.GetDifficultyAsync(beatmapInfo, rulesetInfo, mods, cancellationToken); + return await base.GetDifficultyAsync(beatmapInfo, rulesetInfo, mods, cancellationToken).ConfigureAwait(false); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index 5e2d5eba5d..c22b6a54e9 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -14,7 +14,6 @@ using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; -using osu.Game.Tests.Visual.Navigation; using osu.Game.Users; namespace osu.Game.Tests.Visual.SongSelect @@ -193,7 +192,7 @@ namespace osu.Game.Tests.Visual.SongSelect }).ToList() }; - return Game.BeatmapManager.Import(beatmapSet).Result; + return Game.BeatmapManager.Import(beatmapSet).Result.Value; } private bool ensureAllBeatmapSetsImported(IEnumerable beatmapSets) => beatmapSets.All(set => set != null); diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 102e5ee425..4811fc979e 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -142,10 +142,12 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("store selected beatmap", () => selected = Beatmap.Value); + AddUntilStep("wait for beatmaps to load", () => songSelect.Carousel.ChildrenOfType().Any()); + AddStep("select next and enter", () => { InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() - .First(b => ((CarouselBeatmap)b.Item).Beatmap != songSelect.Carousel.SelectedBeatmap)); + .First(b => ((CarouselBeatmap)b.Item).BeatmapInfo != songSelect.Carousel.SelectedBeatmapInfo)); InputManager.Click(MouseButton.Left); @@ -172,7 +174,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select next and enter", () => { InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() - .First(b => ((CarouselBeatmap)b.Item).Beatmap != songSelect.Carousel.SelectedBeatmap)); + .First(b => ((CarouselBeatmap)b.Item).BeatmapInfo != songSelect.Carousel.SelectedBeatmapInfo)); InputManager.PressButton(MouseButton.Left); @@ -312,7 +314,7 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addRulesetImportStep(2); - AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmap == null); + AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -322,13 +324,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); addRulesetImportStep(1); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.RulesetID == 2); changeRuleset(1); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 1); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.RulesetID == 1); changeRuleset(0); - AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmap == null); + AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -338,7 +340,7 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.RulesetID == 2); addRulesetImportStep(0); addRulesetImportStep(0); @@ -355,7 +357,7 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.Equals(target)); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(target)); // this is an important check, to make sure updateComponentFromBeatmap() was actually run AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.Equals(target)); @@ -368,7 +370,7 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.RulesetID == 2); addRulesetImportStep(0); addRulesetImportStep(0); @@ -385,7 +387,7 @@ namespace osu.Game.Tests.Visual.SongSelect Ruleset.Value = rulesets.AvailableRulesets.First(r => r.ID == 0); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.Equals(target)); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(target)); AddUntilStep("has correct ruleset", () => Ruleset.Value.ID == 0); @@ -444,7 +446,7 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addManyTestMaps(); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); bool startRequested = false; @@ -473,13 +475,13 @@ namespace osu.Game.Tests.Visual.SongSelect // used for filter check below AddStep("allow convert display", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmap == null); + AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); BeatmapInfo target = null; @@ -494,7 +496,7 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); AddAssert("selected only shows expected ruleset (plus converts)", () => { @@ -502,16 +504,16 @@ namespace osu.Game.Tests.Visual.SongSelect // special case for converts checked here. return selectedPanel.ChildrenOfType().All(i => - i.IsFiltered || i.Item.Beatmap.Ruleset.ID == targetRuleset || i.Item.Beatmap.Ruleset.ID == 0); + i.IsFiltered || i.Item.BeatmapInfo.Ruleset.ID == targetRuleset || i.Item.BeatmapInfo.Ruleset.ID == 0); }); - AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmap?.OnlineBeatmapID == target.OnlineBeatmapID); + AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmapInfo?.OnlineBeatmapID == target.OnlineBeatmapID); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); AddStep("reset filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = string.Empty); AddAssert("game still correct", () => Beatmap.Value?.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); - AddAssert("carousel still correct", () => songSelect.Carousel.SelectedBeatmap.OnlineBeatmapID == target.OnlineBeatmapID); + AddAssert("carousel still correct", () => songSelect.Carousel.SelectedBeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); } [Test] @@ -522,13 +524,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(0); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmap == null); + AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); BeatmapInfo target = null; @@ -540,15 +542,15 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); - AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmap?.OnlineBeatmapID == target.OnlineBeatmapID); + AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmapInfo?.OnlineBeatmapID == target.OnlineBeatmapID); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nononoo"); AddUntilStep("game lost selection", () => Beatmap.Value is DummyWorkingBeatmap); - AddAssert("carousel lost selection", () => songSelect.Carousel.SelectedBeatmap == null); + AddAssert("carousel lost selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -581,9 +583,9 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); addRulesetImportStep(0); AddStep("Move to last difficulty", () => songSelect.Carousel.SelectBeatmap(songSelect.Carousel.BeatmapSets.First().Beatmaps.Last())); - AddStep("Store current ID", () => previousID = songSelect.Carousel.SelectedBeatmap.ID); + AddStep("Store current ID", () => previousID = songSelect.Carousel.SelectedBeatmapInfo.ID); AddStep("Hide first beatmap", () => manager.Hide(songSelect.Carousel.SelectedBeatmapSet.Beatmaps.First())); - AddAssert("Selected beatmap has not changed", () => songSelect.Carousel.SelectedBeatmap.ID == previousID); + AddAssert("Selected beatmap has not changed", () => songSelect.Carousel.SelectedBeatmapInfo.ID == previousID); } [Test] @@ -599,10 +601,10 @@ namespace osu.Game.Tests.Visual.SongSelect }); FilterableDifficultyIcon difficultyIcon = null; - AddStep("Find an icon", () => + AddUntilStep("Find an icon", () => { - difficultyIcon = set.ChildrenOfType() - .First(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); + return (difficultyIcon = set.ChildrenOfType() + .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex())) != null; }); AddStep("Click on a difficulty", () => @@ -641,7 +643,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Click(MouseButton.Left); }); - AddAssert("Selected beatmap correct", () => songSelect.Carousel.SelectedBeatmap == filteredBeatmap); + AddAssert("Selected beatmap correct", () => songSelect.Carousel.SelectedBeatmapInfo == filteredBeatmap); } [Test] @@ -714,10 +716,11 @@ namespace osu.Game.Tests.Visual.SongSelect }); FilterableDifficultyIcon difficultyIcon = null; - AddStep("Find an icon for different ruleset", () => + AddUntilStep("Find an icon for different ruleset", () => { difficultyIcon = set.ChildrenOfType() - .First(icon => icon.Item.Beatmap.Ruleset.ID == 3); + .FirstOrDefault(icon => icon.Item.BeatmapInfo.Ruleset.ID == 3); + return difficultyIcon != null; }); AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); @@ -735,7 +738,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.ID == 3); - AddAssert("Selected beatmap still same set", () => songSelect.Carousel.SelectedBeatmap.BeatmapSet.ID == previousSetID); + AddAssert("Selected beatmap still same set", () => songSelect.Carousel.SelectedBeatmapInfo.BeatmapSet.ID == previousSetID); AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.ID == 3); } @@ -751,7 +754,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("import huge difficulty count map", () => { var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); - imported = manager.Import(createTestBeatmapSet(usableRulesets, 50)).Result; + imported = manager.Import(createTestBeatmapSet(usableRulesets, 50)).Result.Value; }); AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported.Beatmaps.First())); @@ -764,10 +767,10 @@ namespace osu.Game.Tests.Visual.SongSelect }); FilterableGroupedDifficultyIcon groupIcon = null; - AddStep("Find group icon for different ruleset", () => + AddUntilStep("Find group icon for different ruleset", () => { - groupIcon = set.ChildrenOfType() - .First(icon => icon.Items.First().Beatmap.Ruleset.ID == 3); + return (groupIcon = set.ChildrenOfType() + .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.ID == 3)) != null; }); AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); @@ -781,7 +784,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.ID == 3); - AddAssert("Check first item in group selected", () => Beatmap.Value.BeatmapInfo.Equals(groupIcon.Items.First().Beatmap)); + AddAssert("Check first item in group selected", () => Beatmap.Value.BeatmapInfo.Equals(groupIcon.Items.First().BeatmapInfo)); } [Test] @@ -805,7 +808,7 @@ namespace osu.Game.Tests.Visual.SongSelect songSelect.PresentScore(new ScoreInfo { User = new User { Username = "woo" }, - Beatmap = getPresentBeatmap(), + BeatmapInfo = getPresentBeatmap(), Ruleset = getPresentBeatmap().Ruleset }); }); @@ -837,7 +840,7 @@ namespace osu.Game.Tests.Visual.SongSelect songSelect.PresentScore(new ScoreInfo { User = new User { Username = "woo" }, - Beatmap = getPresentBeatmap(), + BeatmapInfo = getPresentBeatmap(), Ruleset = getPresentBeatmap().Ruleset }); }); @@ -856,7 +859,7 @@ namespace osu.Game.Tests.Visual.SongSelect private int getBeatmapIndex(BeatmapSetInfo set, BeatmapInfo info) => set.Beatmaps.FindIndex(b => b == info); - private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmap); + private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmapInfo); private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, FilterableDifficultyIcon icon) { diff --git a/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs b/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs index c55988d1bb..7729ad0ff3 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs @@ -5,8 +5,8 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; -using osu.Framework.Testing; using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; using osu.Game.Screens; using osu.Game.Screens.Play; using osuTK.Graphics; @@ -18,10 +18,18 @@ namespace osu.Game.Tests.Visual { private TestOsuScreenStack stack; - [SetUpSteps] - public void SetUpSteps() + [Cached] + private MusicController musicController = new MusicController(); + + [BackgroundDependencyLoader] + private void load() { - AddStep("Create new screen stack", () => { Child = stack = new TestOsuScreenStack { RelativeSizeAxes = Axes.Both }; }); + stack = new TestOsuScreenStack { RelativeSizeAxes = Axes.Both }; + + Add(musicController); + Add(stack); + + LoadComponent(stack); } [Test] @@ -42,6 +50,44 @@ namespace osu.Game.Tests.Visual AddAssert("Parallax is off", () => stack.ParallaxAmount == 0); } + [Test] + public void AllowTrackAdjustmentsTest() + { + AddStep("push allowing screen", () => stack.Push(loadNewScreen())); + AddAssert("allows adjustments 1", () => musicController.AllowTrackAdjustments); + + AddStep("push inheriting screen", () => stack.Push(loadNewScreen())); + AddAssert("allows adjustments 2", () => musicController.AllowTrackAdjustments); + + AddStep("push disallowing screen", () => stack.Push(loadNewScreen())); + AddAssert("disallows adjustments 3", () => !musicController.AllowTrackAdjustments); + + AddStep("push inheriting screen", () => stack.Push(loadNewScreen())); + AddAssert("disallows adjustments 4", () => !musicController.AllowTrackAdjustments); + + AddStep("push inheriting screen", () => stack.Push(loadNewScreen())); + AddAssert("disallows adjustments 5", () => !musicController.AllowTrackAdjustments); + + AddStep("push allowing screen", () => stack.Push(loadNewScreen())); + AddAssert("allows adjustments 6", () => musicController.AllowTrackAdjustments); + + // Now start exiting from screens + AddStep("exit screen", () => stack.Exit()); + AddAssert("disallows adjustments 7", () => !musicController.AllowTrackAdjustments); + + AddStep("exit screen", () => stack.Exit()); + AddAssert("disallows adjustments 8", () => !musicController.AllowTrackAdjustments); + + AddStep("exit screen", () => stack.Exit()); + AddAssert("disallows adjustments 9", () => !musicController.AllowTrackAdjustments); + + AddStep("exit screen", () => stack.Exit()); + AddAssert("allows adjustments 10", () => musicController.AllowTrackAdjustments); + + AddStep("exit screen", () => stack.Exit()); + AddAssert("allows adjustments 11", () => musicController.AllowTrackAdjustments); + } + public class TestScreen : ScreenWithBeatmapBackground { private readonly string screenText; @@ -78,5 +124,26 @@ namespace osu.Game.Tests.Visual { public new float ParallaxAmount => base.ParallaxAmount; } + + private class AllowScreen : OsuScreen + { + public override bool? AllowTrackAdjustments => true; + } + + public class DisallowScreen : OsuScreen + { + public override bool? AllowTrackAdjustments => false; + } + + private class InheritScreen : OsuScreen + { + } + + private OsuScreen loadNewScreen() where T : OsuScreen, new() + { + OsuScreen screen = new T(); + LoadComponent(screen); + return screen; + } } } 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/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs index abd1baf0ac..008d91f649 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using Humanizer; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -73,7 +74,7 @@ namespace osu.Game.Tests.Visual.UserInterface }; control.Query.BindValueChanged(q => query.Text = $"Query: {q.NewValue}", true); - control.General.BindCollectionChanged((u, v) => general.Text = $"General: {(control.General.Any() ? string.Join('.', control.General.Select(i => i.ToString().ToLowerInvariant())) : "")}", true); + control.General.BindCollectionChanged((u, v) => general.Text = $"General: {(control.General.Any() ? string.Join('.', control.General.Select(i => i.ToString().Underscore())) : "")}", true); control.Ruleset.BindValueChanged(r => ruleset.Text = $"Ruleset: {r.NewValue}", true); control.Category.BindValueChanged(c => category.Text = $"Category: {c.NewValue}", true); control.Genre.BindValueChanged(g => genre.Text = $"Genre: {g.NewValue}", true); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs index 53693d1b70..3b43f8485a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -68,13 +70,40 @@ namespace osu.Game.Tests.Visual.UserInterface ); } - private class MyContextMenuContainer : Container, IHasContextMenu + private static MenuItem[] makeMenu() { - public MenuItem[] ContextMenuItems => new MenuItem[] + return new MenuItem[] { new OsuMenuItem(@"Some option"), new OsuMenuItem(@"Highlighted option", MenuItemType.Highlighted), new OsuMenuItem(@"Another option"), + new OsuMenuItem(@"Nested option >") + { + Items = new MenuItem[] + { + new OsuMenuItem(@"Sub-One"), + new OsuMenuItem(@"Sub-Two"), + new OsuMenuItem(@"Sub-Three"), + new OsuMenuItem(@"Sub-Nested option >") + { + Items = new MenuItem[] + { + new OsuMenuItem(@"Double Sub-One"), + new OsuMenuItem(@"Double Sub-Two"), + new OsuMenuItem(@"Double Sub-Three"), + new OsuMenuItem(@"Sub-Sub-Nested option >") + { + Items = new MenuItem[] + { + new OsuMenuItem(@"Too Deep One"), + new OsuMenuItem(@"Too Deep Two"), + new OsuMenuItem(@"Too Deep Three"), + } + } + } + } + } + }, new OsuMenuItem(@"Choose me please"), new OsuMenuItem(@"And me too"), new OsuMenuItem(@"Trying to fill"), @@ -82,17 +111,29 @@ namespace osu.Game.Tests.Visual.UserInterface }; } + private class MyContextMenuContainer : Container, IHasContextMenu + { + public MenuItem[] ContextMenuItems => makeMenu(); + } + private class AnotherContextMenuContainer : Container, IHasContextMenu { - public MenuItem[] ContextMenuItems => new MenuItem[] + public MenuItem[] ContextMenuItems { - new OsuMenuItem(@"Simple option"), - new OsuMenuItem(@"Simple very very long option"), - new OsuMenuItem(@"Change width", MenuItemType.Highlighted, () => this.ResizeWidthTo(Width * 2, 100, Easing.OutQuint)), - new OsuMenuItem(@"Change height", MenuItemType.Highlighted, () => this.ResizeHeightTo(Height * 2, 100, Easing.OutQuint)), - new OsuMenuItem(@"Change width back", MenuItemType.Destructive, () => this.ResizeWidthTo(Width / 2, 100, Easing.OutQuint)), - new OsuMenuItem(@"Change height back", MenuItemType.Destructive, () => this.ResizeHeightTo(Height / 2, 100, Easing.OutQuint)), - }; + get + { + List items = makeMenu().ToList(); + items.AddRange(new MenuItem[] + { + new OsuMenuItem(@"Change width", MenuItemType.Highlighted, () => this.ResizeWidthTo(Width * 2, 100, Easing.OutQuint)), + new OsuMenuItem(@"Change height", MenuItemType.Highlighted, () => this.ResizeHeightTo(Height * 2, 100, Easing.OutQuint)), + new OsuMenuItem(@"Change width back", MenuItemType.Destructive, () => this.ResizeWidthTo(Width / 2, 100, Easing.OutQuint)), + new OsuMenuItem(@"Change height back", MenuItemType.Destructive, () => this.ResizeHeightTo(Height / 2, 100, Easing.OutQuint)), + }); + + return items.ToArray(); + } + } } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 3f9e0048dd..9a75d3c309 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -36,8 +36,9 @@ namespace osu.Game.Tests.Visual.UserInterface private BeatmapManager beatmapManager; private ScoreManager scoreManager; - private readonly List scores = new List(); - private BeatmapInfo beatmap; + private readonly List importedScores = new List(); + + private BeatmapInfo beatmapInfo; [Cached] private readonly DialogOverlay dialogOverlay; @@ -55,7 +56,7 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.Centre, Size = new Vector2(550f, 450f), Scope = BeatmapLeaderboardScope.Local, - Beatmap = new BeatmapInfo + BeatmapInfo = new BeatmapInfo { ID = 1, Metadata = new BeatmapMetadata @@ -82,17 +83,17 @@ namespace osu.Game.Tests.Visual.UserInterface 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)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory, Scheduler)); - beatmap = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).Result.Beatmaps[0]; + beatmapInfo = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).Result.Value.Beatmaps[0]; for (int i = 0; i < 50; i++) { var score = new ScoreInfo { OnlineScoreID = i, - Beatmap = beatmap, - BeatmapInfoID = beatmap.ID, + BeatmapInfo = beatmapInfo, + BeatmapInfoID = beatmapInfo.ID, Accuracy = RNG.NextDouble(), TotalScore = RNG.Next(1, 1000000), MaxCombo = RNG.Next(1, 1000), @@ -100,11 +101,9 @@ namespace osu.Game.Tests.Visual.UserInterface User = new User { Username = "TestUser" }, }; - scores.Add(scoreManager.Import(score).Result); + importedScores.Add(scoreManager.Import(score).Result.Value); } - scores.Sort(Comparer.Create((s1, s2) => s2.TotalScore.CompareTo(s1.TotalScore))); - return dependencies; } @@ -117,7 +116,7 @@ namespace osu.Game.Tests.Visual.UserInterface leaderboard.Scores = null; leaderboard.FinishTransforms(true); // After setting scores, we may be waiting for transforms to expire drawables - leaderboard.Beatmap = beatmap; + leaderboard.BeatmapInfo = beatmapInfo; leaderboard.RefreshScores(); // Required in the case that the beatmap hasn't changed }); @@ -134,9 +133,14 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestDeleteViaRightClick() { + ScoreInfo scoreBeingDeleted = null; AddStep("open menu for top score", () => { - InputManager.MoveMouseTo(leaderboard.ChildrenOfType().First()); + var leaderboardScore = leaderboard.ChildrenOfType().First(); + + scoreBeingDeleted = leaderboardScore.Score; + + InputManager.MoveMouseTo(leaderboardScore); InputManager.Click(MouseButton.Right); }); @@ -158,14 +162,16 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.Click(MouseButton.Left); }); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scores[0].OnlineScoreID)); + AddUntilStep("wait for fetch", () => leaderboard.Scores != null); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scoreBeingDeleted.OnlineScoreID)); } [Test] public void TestDeleteViaDatabase() { - AddStep("delete top score", () => scoreManager.Delete(scores[0])); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scores[0].OnlineScoreID)); + AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); + AddUntilStep("wait for fetch", () => leaderboard.Scores != null); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != importedScores[0].OnlineScoreID)); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs index f5cba2c900..405461eec8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs @@ -24,9 +24,10 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestBasic() { - TestPopupDialog dialog = null; + TestPopupDialog firstDialog = null; + TestPopupDialog secondDialog = null; - AddStep("dialog #1", () => overlay.Push(dialog = new TestPopupDialog + AddStep("dialog #1", () => overlay.Push(firstDialog = new TestPopupDialog { Icon = FontAwesome.Regular.TrashAlt, HeaderText = @"Confirm deletion of", @@ -46,9 +47,9 @@ namespace osu.Game.Tests.Visual.UserInterface }, })); - AddAssert("first dialog displayed", () => overlay.CurrentDialog == dialog); + AddAssert("first dialog displayed", () => overlay.CurrentDialog == firstDialog); - AddStep("dialog #2", () => overlay.Push(dialog = new TestPopupDialog + AddStep("dialog #2", () => overlay.Push(secondDialog = new TestPopupDialog { Icon = FontAwesome.Solid.Cog, HeaderText = @"What do you want to do with", @@ -82,30 +83,33 @@ namespace osu.Game.Tests.Visual.UserInterface }, })); - AddAssert("second dialog displayed", () => overlay.CurrentDialog == dialog); + AddAssert("second dialog displayed", () => overlay.CurrentDialog == secondDialog); + AddAssert("first dialog is not part of hierarchy", () => firstDialog.Parent == null); } [Test] public void TestDismissBeforePush() { + TestPopupDialog testDialog = null; AddStep("dismissed dialog push", () => { - overlay.Push(new TestPopupDialog + overlay.Push(testDialog = new TestPopupDialog { State = { Value = Visibility.Hidden } }); }); AddAssert("no dialog pushed", () => overlay.CurrentDialog == null); + AddAssert("dialog is not part of hierarchy", () => testDialog.Parent == null); } [Test] public void TestDismissBeforePushViaButtonPress() { + TestPopupDialog testDialog = null; AddStep("dismissed dialog push", () => { - TestPopupDialog dialog; - overlay.Push(dialog = new TestPopupDialog + overlay.Push(testDialog = new TestPopupDialog { Buttons = new PopupDialogButton[] { @@ -113,10 +117,11 @@ namespace osu.Game.Tests.Visual.UserInterface }, }); - dialog.PerformOkAction(); + testDialog.PerformOkAction(); }); AddAssert("no dialog pushed", () => overlay.CurrentDialog == null); + AddAssert("dialog is not part of hierarchy", () => testDialog.Parent == null); } private class TestPopupDialog : PopupDialog diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs index 546e905ded..8d1572769f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs @@ -36,9 +36,9 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep(@"Add DoubleTime", () => changeMods(doubleTimeMod)); AddAssert(@"Check DoubleTime multiplier", () => assertModsMultiplier(doubleTimeMod)); - var mutlipleIncrementMods = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModHardRock() }; - AddStep(@"Add multiple Mods", () => changeMods(mutlipleIncrementMods)); - AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(mutlipleIncrementMods)); + var multipleIncrementMods = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModHardRock() }; + AddStep(@"Add multiple Mods", () => changeMods(multipleIncrementMods)); + AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(multipleIncrementMods)); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs index 826da17ca8..e1ea02ba67 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs @@ -1,16 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLabelledColourPalette : OsuTestScene + public class TestSceneLabelledColourPalette : OsuManualInputManagerTestScene { private LabelledColourPalette component; @@ -30,21 +36,45 @@ namespace osu.Game.Tests.Visual.UserInterface }, 8); } + [Test] + public void TestUserInteractions() + { + createColourPalette(); + assertColourCount(4); + + clickAddColour(); + assertColourCount(5); + + deleteFirstColour(); + assertColourCount(4); + + clickFirstColour(); + AddAssert("colour picker spawned", () => this.ChildrenOfType().Any()); + } + private void createColourPalette(bool hasDescription = false) { AddStep("create component", () => { - Child = new Container + Child = new PopoverContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 500, - AutoSizeAxes = Axes.Y, - Child = component = new LabelledColourPalette + RelativeSizeAxes = Axes.Both, + Child = new OsuContextMenuContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - ColourNamePrefix = "My colour #" + RelativeSizeAxes = Axes.Both, + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Child = component = new LabelledColourPalette + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ColourNamePrefix = "My colour #" + } + } } }; @@ -53,18 +83,49 @@ namespace osu.Game.Tests.Visual.UserInterface component.Colours.AddRange(new[] { - Color4.DarkRed, - Color4.Aquamarine, - Color4.Goldenrod, - Color4.Gainsboro + Colour4.DarkRed, + Colour4.Aquamarine, + Colour4.Goldenrod, + Colour4.Gainsboro }); }); } - private Color4 randomColour() => new Color4( + private Colour4 randomColour() => new Color4( RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1); + + private void assertColourCount(int count) => AddAssert($"colour count is {count}", () => component.Colours.Count == count); + + private void clickAddColour() => AddStep("click new colour button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + private void clickFirstColour() => AddStep("click first colour", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + private void deleteFirstColour() + { + AddStep("right-click first colour", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for menu", () => this.ChildrenOfType().Any()); + + AddStep("click delete", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs new file mode 100644 index 0000000000..4b74e37ec4 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneLabelledDropdown : OsuTestScene + { + [Test] + public void TestLabelledDropdown() + => AddStep(@"create dropdown", () => Child = new LabelledDropdown + { + Label = @"Countdown speed", + Items = new[] + { + @"Half", + @"Normal", + @"Double" + }, + Description = @"This is a description" + }); + + [Test] + public void TestLabelledEnumDropdown() + => AddStep(@"create dropdown", () => Child = new LabelledEnumDropdown + { + Label = @"Beatmap status", + Description = @"This is a description" + }); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs new file mode 100644 index 0000000000..f8652573f4 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs @@ -0,0 +1,281 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.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); + } + + [Test] + public void TestModSettingChangeTracker() + { + ModSettingChangeTracker tracker = null; + Queue settingsChangedQueue = null; + + setBeatmapWithDifficultyParameters(5); + + AddStep("add mod settings change tracker", () => + { + settingsChangedQueue = new Queue(); + + tracker = new ModSettingChangeTracker(modDifficultyAdjust.Yield()) + { + SettingChanged = settingsChangedQueue.Enqueue + }; + }); + + AddAssert("no settings changed", () => settingsChangedQueue.Count == 0); + + setSliderValue("Circle Size", 3); + + settingsChangedFired(); + + setSliderValue("Circle Size", 5); + checkBindableAtValue("Circle Size", 5); + + settingsChangedFired(); + + AddStep("reset mod settings", () => modDifficultyAdjust.CircleSize.SetDefault()); + checkBindableAtValue("Circle Size", null); + + settingsChangedFired(); + + setExtendedLimits(true); + + settingsChangedFired(); + + AddStep("dispose tracker", () => + { + tracker.Dispose(); + tracker = null; + }); + + void settingsChangedFired() + { + AddAssert("setting changed event fired", () => + { + settingsChangedQueue.Dequeue(); + return settingsChangedQueue.Count == 0; + }); + } + } + + 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/TestSceneModDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs index 8168faa106..b8f5ee5e86 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs @@ -11,10 +11,8 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneModDisplay : OsuTestScene { - [TestCase(ExpansionMode.ExpandOnHover)] - [TestCase(ExpansionMode.AlwaysExpanded)] - [TestCase(ExpansionMode.AlwaysContracted)] - public void TestMode(ExpansionMode mode) + [Test] + public void TestMode([Values] ExpansionMode mode) { AddStep("create mod display", () => { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs new file mode 100644 index 0000000000..10eab148de --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.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.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModFlowDisplay : OsuTestScene + { + private ModFlowDisplay modFlow; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = modFlow = new ModFlowDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.None, + Width = 200, + Current = + { + Value = new OsuRuleset().CreateAllMods().ToArray(), + } + }; + }); + + [Test] + public void TestWrapping() + { + AddSliderStep("icon size", 0.1f, 2, 1, val => + { + if (modFlow != null) + modFlow.IconScale = val; + }); + + AddSliderStep("flow width", 100, 500, 200, val => + { + if (modFlow != null) + modFlow.Width = val; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs index e7fa7d9235..513eb2fafc 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; @@ -17,5 +19,16 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("create mod icon", () => Child = icon = new ModIcon(new OsuModDoubleTime())); AddStep("change mod", () => icon.Mod = new OsuModEasy()); } + + [Test] + public void TestInterfaceModType() + { + ModIcon icon = null; + + var ruleset = new OsuRuleset(); + + AddStep("create mod icon", () => Child = icon = new ModIcon(ruleset.AllMods.First(m => m.Acronym == "DT"))); + AddStep("change mod", () => icon.Mod = ruleset.AllMods.First(m => m.Acronym == "EZ")); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index df8ef92a05..4f7aec3b67 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; 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; @@ -50,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() { @@ -61,10 +94,10 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("selected mod matches", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.Value == 1.2); - AddStep("deselect", () => modSelect.DeselectAllButton.Click()); + AddStep("deselect", () => modSelect.DeselectAllButton.TriggerClick()); AddAssert("selected mods empty", () => SelectedMods.Value.Count == 0); - AddStep("reselect", () => modSelect.GetModButton(osuModDoubleTime).Click()); + AddStep("reselect", () => modSelect.GetModButton(osuModDoubleTime).TriggerClick()); AddAssert("selected mod has default value", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.IsDefault == true); } @@ -103,14 +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 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); @@ -118,8 +148,6 @@ namespace osu.Game.Tests.Visual.UserInterface testMultiMod(doubleTimeMod); testIncompatibleMods(easy, hardRock); testDeselectAll(easierMods.Where(m => !(m is MultiMod))); - - testUnimplementedMod(targetMod); } [Test] @@ -130,8 +158,8 @@ namespace osu.Game.Tests.Visual.UserInterface var mania = new ManiaRuleset(); testModsWithSameBaseType( - mania.GetAllMods().Single(m => m.GetType() == typeof(ManiaModFadeIn)), - mania.GetAllMods().Single(m => m.GetType() == typeof(ManiaModHidden))); + mania.CreateMod(), + mania.CreateMod()); } [Test] @@ -249,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); @@ -343,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); @@ -369,14 +416,13 @@ namespace osu.Game.Tests.Visual.UserInterface { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, Position = new Vector2(-5, 25), Current = { BindTarget = modSelect.SelectedMods } } }; } - private class TestModSelectOverlay : LocalPlayerModSelectOverlay + private class TestModSelectOverlay : UserModSelectOverlay { public new Bindable> SelectedMods => base.SelectedMods; @@ -401,5 +447,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..da0fa5d76d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for button load", () => modSelect.ButtonsLoaded); AddStep("select mod", () => modSelect.SelectMod(testCustomisableMod)); AddAssert("button enabled", () => modSelect.CustomiseButton.Enabled.Value); - AddStep("open Customisation", () => modSelect.CustomiseButton.Click()); + AddStep("open Customisation", () => modSelect.CustomiseButton.TriggerClick()); AddStep("deselect mod", () => modSelect.SelectMod(testCustomisableMod)); AddAssert("controls hidden", () => modSelect.ModSettingsContainer.State.Value == Visibility.Hidden); } @@ -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); @@ -151,7 +151,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for ready", () => modSelect.State.Value == Visibility.Visible && modSelect.ButtonsLoaded); } - private class TestModSelectOverlay : LocalPlayerModSelectOverlay + private class TestModSelectOverlay : UserModSelectOverlay { public new VisibilityContainer ModSettingsContainer => base.ModSettingsContainer; public new TriangleButton CustomiseButton => base.CustomiseButton; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuFont.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuFont.cs new file mode 100644 index 0000000000..eedafce271 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuFont.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuFont : OsuTestScene + { + private OsuSpriteText spriteText; + + private readonly BindableBool useAlternates = new BindableBool(); + private readonly Bindable weight = new Bindable(FontWeight.Regular); + + [BackgroundDependencyLoader] + private void load() + { + Child = spriteText = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AllowMultiline = true, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + useAlternates.BindValueChanged(_ => updateFont()); + weight.BindValueChanged(_ => updateFont(), true); + } + + private void updateFont() + { + FontUsage usage = useAlternates.Value ? OsuFont.TorusAlternate : OsuFont.Torus; + spriteText.Font = usage.With(size: 40, weight: weight.Value); + } + + [Test] + public void TestTorusAlternates() + { + AddStep("set all ASCII letters", () => spriteText.Text = @"ABCDEFGHIJKLMNOPQRSTUVWXYZ +abcdefghijklmnopqrstuvwxyz"); + AddStep("set all alternates", () => spriteText.Text = @"A Á Ă Â Ä À Ā Ą Å Ã +Æ B D Ð Ď Đ E É Ě Ê +Ë Ė È Ē Ę F G Ğ Ģ Ġ +H I Í Î Ï İ Ì Ī Į K +Ķ O Œ P Þ Q R Ŕ Ř Ŗ +T Ŧ Ť Ţ Ț V W Ẃ Ŵ Ẅ +Ẁ X Y Ý Ŷ Ÿ Ỳ a á ă +â ä à ā ą å ã æ b d +ď đ e é ě ê ë ė è ē +ę f g ğ ģ ġ k ķ m n +ń ň ņ ŋ ñ o œ p þ q +t ŧ ť ţ ț u ú û ü ù +ű ū ų ů w ẃ ŵ ẅ ẁ x +y ý ŷ ÿ ỳ"); + + AddToggleStep("toggle alternates", alternates => useAlternates.Value = alternates); + + addSetWeightStep(FontWeight.Light); + addSetWeightStep(FontWeight.Regular); + addSetWeightStep(FontWeight.SemiBold); + addSetWeightStep(FontWeight.Bold); + + void addSetWeightStep(FontWeight newWeight) => AddStep($"set weight {newWeight}", () => weight.Value = newWeight); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs new file mode 100644 index 0000000000..8b91339479 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuLogo : OsuTestScene + { + [Test] + public void TestBasic() + { + AddStep("Add logo", () => + { + Child = new OsuLogo + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + } + } +} 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/TestSceneRoundedButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs new file mode 100644 index 0000000000..9ccfba7c74 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneRoundedButton : OsuTestScene + { + [Test] + public void TestBasic() + { + RoundedButton button = null; + + AddStep("create button", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.DarkGray + }, + button = new RoundedButton + { + Width = 400, + Text = "Test button", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => { } + } + } + }); + + AddToggleStep("toggle disabled", disabled => button.Action = disabled ? (Action)null : () => { }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs index 5c2e6e457d..2312c57af2 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics.Containers; using osuTK.Graphics; @@ -61,10 +62,12 @@ namespace osu.Game.Tests.Visual.UserInterface )); AddStep("scroll up", () => triggerUserScroll(1)); AddStep("scroll down", () => triggerUserScroll(-1)); + AddStep("scroll up a bit", () => triggerUserScroll(0.1f)); + AddStep("scroll down a bit", () => triggerUserScroll(-0.1f)); } [Test] - public void TestCorrectSectionSelected() + public void TestCorrectSelectionAndVisibleTop() { const int sections_count = 11; float[] alternating = { 0.07f, 0.33f, 0.16f, 0.33f }; @@ -79,6 +82,12 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep($"scroll to section {scrollIndex + 1}", () => container.ScrollTo(container.Children[scrollIndex])); AddUntilStep("correct section selected", () => container.SelectedSection.Value == container.Children[scrollIndex]); + AddUntilStep("section top is visible", () => + { + float scrollPosition = container.ChildrenOfType().First().Current; + float sectionTop = container.Children[scrollIndex].BoundingBox.Top; + return scrollPosition < sectionTop; + }); } for (int i = 1; i < sections_count; i++) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs new file mode 100644 index 0000000000..2806e6d347 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneStarRatingDisplay : OsuTestScene + { + [TestCase(StarRatingDisplaySize.Regular)] + [TestCase(StarRatingDisplaySize.Small)] + public void TestDisplay(StarRatingDisplaySize size) + { + AddStep("load displays", () => + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2f), + Direction = FillDirection.Horizontal, + ChildrenEnumerable = Enumerable.Range(0, 15).Select(i => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2f), + Direction = FillDirection.Vertical, + ChildrenEnumerable = Enumerable.Range(0, 10).Select(j => new StarRatingDisplay(new StarDifficulty(i * (i >= 11 ? 25f : 1f) + j * 0.1f, 0), size) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }), + }) + }; + }); + } + + [Test] + public void TestSpectrum() + { + StarRatingDisplay starRating = null; + + AddStep("load display", () => Child = starRating = new StarRatingDisplay(new StarDifficulty(5.55, 1), animated: true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(3f), + }); + + AddRepeatStep("set random value", () => + { + starRating.Current.Value = new StarDifficulty(RNG.NextDouble(0.0, 11.0), 1); + }, 10); + + AddSliderStep("set exact stars", 0.0, 11.0, 5.55, d => + { + if (starRating != null) + starRating.Current.Value = new StarDifficulty(d, 1); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs index 198cc70e01..74cd675a05 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs @@ -72,7 +72,7 @@ namespace osu.Game.Tests.Visual.UserInterface var req = new GetBeatmapSetRequest(1); api.Queue(req); - AddUntilStep("wait for api response", () => req.Result != null); + AddUntilStep("wait for api response", () => req.Response != null); TestUpdateableBeatmapBackgroundSprite background = null; @@ -81,7 +81,7 @@ namespace osu.Game.Tests.Visual.UserInterface Child = background = new TestUpdateableBeatmapBackgroundSprite { RelativeSizeAxes = Axes.Both, - Beatmap = { Value = new BeatmapInfo { BeatmapSet = req.Result?.ToBeatmapSet(rulesets) } } + Beatmap = { Value = new BeatmapInfo { BeatmapSet = req.Response?.ToBeatmapSet(rulesets) } } }; }); 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/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index 5477e4a0f8..9c85fa0c9c 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests protected override Waveform GetWaveform() => new Waveform(trackStore.GetStream(firstAudioFile)); - protected override ISkin GetSkin() => null; + protected internal override ISkin GetSkin() => null; public override Stream GetStream(string storagePath) => null; diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 161e248d96..cd56cb51ae 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -3,7 +3,8 @@ - + + diff --git a/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs index acd5d53310..11b5cc7556 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tournament.Tests.Components public TestSceneMatchScoreDisplay() { - Add(new MatchScoreDisplay + Add(new TournamentMatchScoreDisplay { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs index bc32a12ab7..f9c553cb3f 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tournament.Tests.Components private void success(APIBeatmap apiBeatmap) { - var beatmap = apiBeatmap.ToBeatmap(rulesets); + var beatmap = apiBeatmap.ToBeatmapInfo(rulesets); Add(new TournamentBeatmapPanel(beatmap) { Anchor = Anchor.Centre, diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs index b4d9fa4222..27eb55a9fb 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tournament.Tests.Components private FillFlowContainer fillFlow; - private BeatmapInfo beatmap; + private BeatmapInfo beatmapInfo; [BackgroundDependencyLoader] private void load() @@ -44,12 +44,12 @@ namespace osu.Game.Tournament.Tests.Components private void success(APIBeatmap apiBeatmap) { - beatmap = apiBeatmap.ToBeatmap(rulesets); - var mods = rulesets.GetRuleset(Ladder.Ruleset.Value.ID ?? 0).CreateInstance().GetAllMods(); + beatmapInfo = apiBeatmap.ToBeatmapInfo(rulesets); + var mods = rulesets.GetRuleset(Ladder.Ruleset.Value.ID ?? 0).CreateInstance().AllMods; foreach (var mod in mods) { - fillFlow.Add(new TournamentBeatmapPanel(beatmap, mod.Acronym) + fillFlow.Add(new TournamentBeatmapPanel(beatmapInfo, mod.Acronym) { Anchor = Anchor.Centre, Origin = Anchor.Centre diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index 61f8511e3c..fcc9f44f0c 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -1,30 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.IO; -using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Platform; -using osu.Game.Tournament.Configuration; using osu.Game.Tests; +using osu.Game.Tournament.Configuration; namespace osu.Game.Tournament.Tests.NonVisual { [TestFixture] - public class CustomTourneyDirectoryTest + public class CustomTourneyDirectoryTest : TournamentHostTest { [Test] public void TestDefaultDirectory() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestDefaultDirectory))) { try { - var osu = loadOsu(host); + var osu = LoadTournament(host); var storage = osu.Dependencies.Get(); Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"))); @@ -54,7 +51,7 @@ namespace osu.Game.Tournament.Tests.NonVisual try { - var osu = loadOsu(host); + var osu = LoadTournament(host); storage = osu.Dependencies.Get(); @@ -111,7 +108,7 @@ namespace osu.Game.Tournament.Tests.NonVisual try { - var osu = loadOsu(host); + var osu = LoadTournament(host); var storage = osu.Dependencies.Get(); @@ -139,32 +136,18 @@ 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(); } } } - private TournamentGameBase loadOsu(GameHost host) - { - var osu = new TournamentGameBase(); - 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; - } - - private static void waitForOrAssert(Func result, string failureMessage, int timeout = 90000) - { - Task task = Task.Run(() => - { - while (!result()) Thread.Sleep(200); - }); - - Assert.IsTrue(task.Wait(timeout), failureMessage); - } - private string basePath(string testInstance) => Path.Combine(RuntimeInfo.StartupDirectory, "headless", testInstance); } } diff --git a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs new file mode 100644 index 0000000000..692cb3870c --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Game.Rulesets; +using osu.Game.Tests; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + public class DataLoadTest : TournamentHostTest + { + [Test] + public void TestUnavailableRuleset() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestUnavailableRuleset))) + { + try + { + var osu = new TestTournament(); + + LoadTournament(host, osu); + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"))); + } + finally + { + host.Exit(); + } + } + } + + public class TestTournament : TournamentGameBase + { + [BackgroundDependencyLoader] + private void load() + { + Ruleset.Value = new RulesetInfo(); // not available + } + } + } +} diff --git a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs index e4eb5a36fb..eaa009c180 100644 --- a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs @@ -1,10 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.IO; -using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework; using osu.Framework.Allocation; @@ -15,7 +12,7 @@ using osu.Game.Tournament.IPC; namespace osu.Game.Tournament.Tests.NonVisual { [TestFixture] - public class IPCLocationTest + public class IPCLocationTest : TournamentHostTest { [Test] public void CheckIPCLocation() @@ -34,11 +31,11 @@ namespace osu.Game.Tournament.Tests.NonVisual try { - var osu = loadOsu(host); + var osu = LoadTournament(host); TournamentStorage storage = (TournamentStorage)osu.Dependencies.Get(); FileBasedIPC ipc = null; - waitForOrAssert(() => (ipc = osu.Dependencies.Get() as FileBasedIPC) != null, @"ipc could not be populated in a reasonable amount of time"); + WaitForOrAssert(() => (ipc = osu.Dependencies.Get() as FileBasedIPC) != null, @"ipc could not be populated in a reasonable amount of time"); Assert.True(ipc.SetIPCLocation(testStableInstallDirectory)); Assert.True(storage.AllTournaments.Exists("stable.json")); @@ -51,24 +48,5 @@ namespace osu.Game.Tournament.Tests.NonVisual } } } - - private TournamentGameBase loadOsu(GameHost host) - { - var osu = new TournamentGameBase(); - 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; - } - - private static void waitForOrAssert(Func result, string failureMessage, int timeout = 90000) - { - Task task = Task.Run(() => - { - while (!result()) Thread.Sleep(200); - }); - - Assert.IsTrue(task.Wait(timeout), failureMessage); - } } } diff --git a/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs new file mode 100644 index 0000000000..319a768e65 --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.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.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Platform; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + public abstract class TournamentHostTest + { + public static TournamentGameBase LoadTournament(GameHost host, TournamentGameBase tournament = null) + { + tournament ??= new TournamentGameBase(); + Task.Factory.StartNew(() => host.Run(tournament), TaskCreationOptions.LongRunning) + .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); + WaitForOrAssert(() => tournament.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + return tournament; + } + + public static void WaitForOrAssert(Func result, string failureMessage, int timeout = 90000) + { + Task task = Task.Run(() => + { + while (!result()) Thread.Sleep(200); + }); + + Assert.IsTrue(task.Wait(timeout), failureMessage); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs index 522567584d..6879a71f1d 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs @@ -4,8 +4,10 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Tournament.Components; +using osu.Game.Tournament.IPC; using osu.Game.Tournament.Screens.Gameplay; using osu.Game.Tournament.Screens.Gameplay.Components; @@ -16,16 +18,26 @@ namespace osu.Game.Tournament.Tests.Screens [Cached] private TournamentMatchChatDisplay chat = new TournamentMatchChatDisplay { Width = 0.5f }; - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestStartupState([Values] TourneyState state) { - Add(new GameplayScreen()); - Add(chat); + AddStep("set state", () => IPCInfo.State.Value = state); + createScreen(); + } + + [Test] + public void TestStartupStateNoCurrentMatch([Values] TourneyState state) + { + AddStep("set null current", () => Ladder.CurrentMatch.Value = null); + AddStep("set state", () => IPCInfo.State.Value = state); + createScreen(); } [Test] public void TestWarmup() { + createScreen(); + checkScoreVisibility(false); toggleWarmup(); @@ -35,11 +47,25 @@ namespace osu.Game.Tournament.Tests.Screens checkScoreVisibility(false); } + private void createScreen() + { + AddStep("setup screen", () => + { + Remove(chat); + + Children = new Drawable[] + { + new GameplayScreen(), + chat, + }; + }); + } + private void checkScoreVisibility(bool visible) => AddUntilStep($"scores {(visible ? "shown" : "hidden")}", () => this.ChildrenOfType().All(score => score.Alpha == (visible ? 1 : 0))); private void toggleWarmup() - => AddStep("toggle warmup", () => this.ChildrenOfType().First().Click()); + => AddStep("toggle warmup", () => this.ChildrenOfType().First().TriggerClick()); } } 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/Screens/TestSceneTeamWinScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs index 3ca58dcaf4..d07cc4c431 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Framework.Allocation; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Tournament.Screens.TeamWin; @@ -10,19 +10,22 @@ namespace osu.Game.Tournament.Tests.Screens { public class TestSceneTeamWinScreen : TournamentTestScene { - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestBasic() { - var match = Ladder.CurrentMatch.Value; + AddStep("set up match", () => + { + var match = Ladder.CurrentMatch.Value; - match.Round.Value = Ladder.Rounds.FirstOrDefault(g => g.Name.Value == "Finals"); - match.Completed.Value = true; + match.Round.Value = Ladder.Rounds.FirstOrDefault(g => g.Name.Value == "Finals"); + match.Completed.Value = true; + }); - Add(new TeamWinScreen + AddStep("create screen", () => Add(new TeamWinScreen { FillMode = FillMode.Fit, FillAspectRatio = 16 / 9f - }); + })); } } } diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index 1fa0ffc8e9..93e1e018a5 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -19,6 +19,8 @@ namespace osu.Game.Tournament.Tests { public abstract class TournamentTestScene : OsuTestScene { + private TournamentMatch match; + [Cached] protected LadderInfo Ladder { get; private set; } = new LadderInfo(); @@ -33,19 +35,23 @@ namespace osu.Game.Tournament.Tests { Ladder.Ruleset.Value ??= rulesetStore.AvailableRulesets.First(); - TournamentMatch match = CreateSampleMatch(); + match = CreateSampleMatch(); Ladder.Rounds.Add(match.Round.Value); Ladder.Matches.Add(match); Ladder.Teams.Add(match.Team1.Value); Ladder.Teams.Add(match.Team2.Value); - Ladder.CurrentMatch.Value = match; - Ruleset.BindTo(Ladder.Ruleset); Dependencies.CacheAs(new StableInfo(storage)); } + [SetUpSteps] + public virtual void SetUpSteps() + { + AddStep("set current match", () => Ladder.CurrentMatch.Value = match); + } + public static TournamentMatch CreateSampleMatch() => new TournamentMatch { Team1 = diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index ba096abd36..2673c9ec9f 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/osu.Game.Tournament/Components/DateTextBox.cs b/osu.Game.Tournament/Components/DateTextBox.cs index 5782301a65..2237e389d7 100644 --- a/osu.Game.Tournament/Components/DateTextBox.cs +++ b/osu.Game.Tournament/Components/DateTextBox.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tournament.Components public DateTextBox() { - base.Current = new Bindable(); + base.Current = new Bindable(string.Empty); ((OsuTextBox)Control).OnCommit += (sender, newText) => { diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index cafec0a88b..13888699ef 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.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.Bindables; using osu.Framework.Graphics; @@ -11,6 +10,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Rulesets; using osu.Game.Screens.Menu; @@ -21,22 +21,22 @@ namespace osu.Game.Tournament.Components { public class SongBar : CompositeDrawable { - private BeatmapInfo beatmap; + private BeatmapInfo beatmapInfo; public const float HEIGHT = 145 / 2f; [Resolved] private IBindable ruleset { get; set; } - public BeatmapInfo Beatmap + public BeatmapInfo BeatmapInfo { - get => beatmap; + get => beatmapInfo; set { - if (beatmap == value) + if (beatmapInfo == value) return; - beatmap = value; + beatmapInfo = value; update(); } } @@ -95,18 +95,18 @@ namespace osu.Game.Tournament.Components private void update() { - if (beatmap == null) + if (beatmapInfo == null) { flow.Clear(); return; } - var bpm = beatmap.BeatmapSet.OnlineInfo.BPM; - var length = beatmap.Length; + var bpm = beatmapInfo.BeatmapSet.OnlineInfo.BPM; + var length = beatmapInfo.Length; string hardRockExtra = ""; string srExtra = ""; - var ar = beatmap.BaseDifficulty.ApproachRate; + var ar = beatmapInfo.BaseDifficulty.ApproachRate; if ((mods & LegacyMods.HardRock) > 0) { @@ -117,7 +117,7 @@ namespace osu.Game.Tournament.Components if ((mods & LegacyMods.DoubleTime) > 0) { // temporary local calculation (taken from OsuDifficultyCalculator) - double preempt = (int)BeatmapDifficulty.DifficultyRange(ar, 1800, 1200, 450) / 1.5; + double preempt = (int)IBeatmapDifficultyInfo.DifficultyRange(ar, 1800, 1200, 450) / 1.5; ar = (float)(preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5); bpm *= 1.5f; @@ -132,9 +132,9 @@ namespace osu.Game.Tournament.Components default: stats = new (string heading, string content)[] { - ("CS", $"{beatmap.BaseDifficulty.CircleSize:0.#}{hardRockExtra}"), + ("CS", $"{beatmapInfo.BaseDifficulty.CircleSize:0.#}{hardRockExtra}"), ("AR", $"{ar:0.#}{hardRockExtra}"), - ("OD", $"{beatmap.BaseDifficulty.OverallDifficulty:0.#}{hardRockExtra}"), + ("OD", $"{beatmapInfo.BaseDifficulty.OverallDifficulty:0.#}{hardRockExtra}"), }; break; @@ -142,15 +142,15 @@ namespace osu.Game.Tournament.Components case 3: stats = new (string heading, string content)[] { - ("OD", $"{beatmap.BaseDifficulty.OverallDifficulty:0.#}{hardRockExtra}"), - ("HP", $"{beatmap.BaseDifficulty.DrainRate:0.#}{hardRockExtra}") + ("OD", $"{beatmapInfo.BaseDifficulty.OverallDifficulty:0.#}{hardRockExtra}"), + ("HP", $"{beatmapInfo.BaseDifficulty.DrainRate:0.#}{hardRockExtra}") }; break; case 2: stats = new (string heading, string content)[] { - ("CS", $"{beatmap.BaseDifficulty.CircleSize:0.#}{hardRockExtra}"), + ("CS", $"{beatmapInfo.BaseDifficulty.CircleSize:0.#}{hardRockExtra}"), ("AR", $"{ar:0.#}"), }; break; @@ -186,7 +186,7 @@ namespace osu.Game.Tournament.Components Children = new Drawable[] { new DiffPiece(stats), - new DiffPiece(("Star Rating", $"{beatmap.StarDifficulty:0.#}{srExtra}")) + new DiffPiece(("Star Rating", $"{beatmapInfo.StarDifficulty:0.#}{srExtra}")) } }, new FillFlowContainer @@ -198,8 +198,8 @@ namespace osu.Game.Tournament.Components Direction = FillDirection.Vertical, Children = new Drawable[] { - new DiffPiece(("Length", TimeSpan.FromMilliseconds(length).ToString(@"mm\:ss"))), - new DiffPiece(("BPM", $"{bpm:0.#}")) + new DiffPiece(("Length", length.ToFormattedDuration().ToString())), + new DiffPiece(("BPM", $"{bpm:0.#}")), } }, new Container @@ -229,7 +229,7 @@ namespace osu.Game.Tournament.Components } } }, - new TournamentBeatmapPanel(beatmap) + new TournamentBeatmapPanel(beatmapInfo) { RelativeSizeAxes = Axes.X, Width = 0.5f, diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index e6d73c6e83..0e5a66e7fe 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tournament.Components { public class TournamentBeatmapPanel : CompositeDrawable { - public readonly BeatmapInfo Beatmap; + public readonly BeatmapInfo BeatmapInfo; private readonly string mod; private const float horizontal_padding = 10; @@ -32,11 +32,11 @@ namespace osu.Game.Tournament.Components private readonly Bindable currentMatch = new Bindable(); private Box flash; - public TournamentBeatmapPanel(BeatmapInfo beatmap, string mod = null) + public TournamentBeatmapPanel(BeatmapInfo beatmapInfo, string mod = null) { - if (beatmap == null) throw new ArgumentNullException(nameof(beatmap)); + if (beatmapInfo == null) throw new ArgumentNullException(nameof(beatmapInfo)); - Beatmap = beatmap; + BeatmapInfo = beatmapInfo; this.mod = mod; Width = 400; Height = HEIGHT; @@ -61,7 +61,7 @@ namespace osu.Game.Tournament.Components { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.5f), - BeatmapSet = Beatmap.BeatmapSet, + BeatmapSet = BeatmapInfo.BeatmapSet, }, new FillFlowContainer { @@ -75,8 +75,8 @@ namespace osu.Game.Tournament.Components new TournamentSpriteText { Text = new RomanisableString( - $"{Beatmap.Metadata.ArtistUnicode ?? Beatmap.Metadata.Artist} - {Beatmap.Metadata.TitleUnicode ?? Beatmap.Metadata.Title}", - $"{Beatmap.Metadata.Artist} - {Beatmap.Metadata.Title}"), + $"{BeatmapInfo.Metadata.ArtistUnicode ?? BeatmapInfo.Metadata.Artist} - {BeatmapInfo.Metadata.TitleUnicode ?? BeatmapInfo.Metadata.Title}", + $"{BeatmapInfo.Metadata.Artist} - {BeatmapInfo.Metadata.Title}"), Font = OsuFont.Torus.With(weight: FontWeight.Bold), }, new FillFlowContainer @@ -93,7 +93,7 @@ namespace osu.Game.Tournament.Components }, new TournamentSpriteText { - Text = Beatmap.Metadata.AuthorString, + Text = BeatmapInfo.Metadata.AuthorString, Padding = new MarginPadding { Right = 20 }, Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, @@ -105,7 +105,7 @@ namespace osu.Game.Tournament.Components }, new TournamentSpriteText { - Text = Beatmap.Version, + Text = BeatmapInfo.Version, Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, } @@ -149,7 +149,7 @@ namespace osu.Game.Tournament.Components private void updateState() { - var found = currentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == Beatmap.OnlineBeatmapID); + var found = currentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == BeatmapInfo.OnlineBeatmapID); bool doFlash = found != choice; choice = found; diff --git a/osu.Game.Tournament/Components/TournamentModIcon.cs b/osu.Game.Tournament/Components/TournamentModIcon.cs index 43ac92d285..7c4e9c69a2 100644 --- a/osu.Game.Tournament/Components/TournamentModIcon.cs +++ b/osu.Game.Tournament/Components/TournamentModIcon.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -49,7 +48,7 @@ namespace osu.Game.Tournament.Components } var ruleset = rulesets.GetRuleset(ladderInfo.Ruleset.Value?.ID ?? 0); - var modIcon = ruleset?.CreateInstance().GetAllMods().FirstOrDefault(mod => mod.Acronym == modAcronym); + var modIcon = ruleset?.CreateInstance().CreateModFromAcronym(modAcronym); if (modIcon == null) return; diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs index f538d4a7d9..7010a30eb7 100644 --- a/osu.Game.Tournament/IPC/FileBasedIPC.cs +++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs @@ -94,7 +94,7 @@ namespace osu.Game.Tournament.IPC else { beatmapLookupRequest = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId }); - beatmapLookupRequest.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets); + beatmapLookupRequest.Success += b => Beatmap.Value = b.ToBeatmapInfo(Rulesets); API.Queue(beatmapLookupRequest); } } diff --git a/osu.Game.Tournament/Models/SeedingResult.cs b/osu.Game.Tournament/Models/SeedingResult.cs index 87aaf8bf36..d37c967762 100644 --- a/osu.Game.Tournament/Models/SeedingResult.cs +++ b/osu.Game.Tournament/Models/SeedingResult.cs @@ -10,7 +10,7 @@ namespace osu.Game.Tournament.Models { public List Beatmaps = new List(); - public Bindable Mod = new Bindable(); + public Bindable Mod = new Bindable(string.Empty); public Bindable Seed = new BindableInt { diff --git a/osu.Game.Tournament/Models/TournamentRound.cs b/osu.Game.Tournament/Models/TournamentRound.cs index 08b3143be1..ab39605d07 100644 --- a/osu.Game.Tournament/Models/TournamentRound.cs +++ b/osu.Game.Tournament/Models/TournamentRound.cs @@ -14,8 +14,8 @@ namespace osu.Game.Tournament.Models [Serializable] public class TournamentRound { - public readonly Bindable Name = new Bindable(); - public readonly Bindable Description = new Bindable(); + public readonly Bindable Name = new Bindable(string.Empty); + public readonly Bindable Description = new Bindable(string.Empty); public readonly BindableInt BestOf = new BindableInt(9) { Default = 9, MinValue = 3, MaxValue = 23 }; diff --git a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs index 0a3163ef43..b94b164116 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; @@ -40,7 +40,7 @@ namespace osu.Game.Tournament.Screens private void beatmapChanged(ValueChangedEvent beatmap) { SongBar.FadeInFromZero(300, Easing.OutQuint); - SongBar.Beatmap = beatmap.NewValue; + SongBar.BeatmapInfo = beatmap.NewValue; } } } diff --git a/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs b/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs index f96ec01cbb..5d035a4028 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs @@ -27,6 +27,9 @@ namespace osu.Game.Tournament.Screens.Drawings.Components { var teams = new List(); + if (!storage.Exists(teams_filename)) + return teams; + try { using (Stream stream = storage.GetStream(teams_filename, FileAccess.Read, FileMode.Open)) diff --git a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs index 4c3adeae76..d02e0ebf86 100644 --- a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs +++ b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs @@ -9,11 +9,13 @@ using System.Threading.Tasks; 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.Textures; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Drawings.Components; @@ -51,6 +53,29 @@ namespace osu.Game.Tournament.Screens.Drawings if (!TeamList.Teams.Any()) { + LinkFlowContainer links; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = 0.3f, + }, + new WarningBox("No drawings.txt file found. Please create one and restart the client."), + links = new LinkFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 60, + AutoSizeAxes = Axes.Both + } + }; + + links.AddLink("Click for details on the file format", "https://osu.ppy.sh/wiki/en/Tournament_Drawings", t => t.Colour = Color4.White); return; } diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index 27ad6650d1..1d8c4e7476 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -149,7 +149,7 @@ namespace osu.Game.Tournament.Screens.Editors private readonly Bindable beatmapId = new Bindable(); - private readonly Bindable mods = new Bindable(); + private readonly Bindable mods = new Bindable(string.Empty); private readonly Container drawableContainer; @@ -238,7 +238,7 @@ namespace osu.Game.Tournament.Screens.Editors req.Success += res => { - Model.BeatmapInfo = res.ToBeatmap(rulesets); + Model.BeatmapInfo = res.ToBeatmapInfo(rulesets); updatePanel(); }; diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 6418bf97da..d5b55823a5 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -149,7 +149,7 @@ namespace osu.Game.Tournament.Screens.Editors private readonly Bindable beatmapId = new Bindable(); - private readonly Bindable score = new Bindable(); + private readonly Bindable score = new Bindable(string.Empty); private readonly Container drawableContainer; @@ -246,7 +246,7 @@ namespace osu.Game.Tournament.Screens.Editors req.Success += res => { - Model.BeatmapInfo = res.ToBeatmap(rulesets); + Model.BeatmapInfo = res.ToBeatmapInfo(rulesets); updatePanel(); }; diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs index 87793f7e1b..2f0e4b5e87 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs @@ -20,6 +20,6 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components } private void matchChanged(ValueChangedEvent match) => - Text.Text = match.NewValue.Round.Value?.Name.Value ?? "Unknown Round"; + Text.Text = match.NewValue?.Round.Value?.Name.Value ?? "Unknown Round"; } } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs similarity index 95% rename from osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs rename to osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs index 695c6d6f3e..77101e4023 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs @@ -16,7 +16,8 @@ using osuTK; namespace osu.Game.Tournament.Screens.Gameplay.Components { - public class MatchScoreDisplay : CompositeDrawable + // TODO: Update to derive from osu-side class? + public class TournamentMatchScoreDisplay : CompositeDrawable { private const float bar_height = 18; @@ -29,7 +30,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components private readonly Drawable score1Bar; private readonly Drawable score2Bar; - public MatchScoreDisplay() + public TournamentMatchScoreDisplay() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -126,7 +127,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth); } - private class MatchScoreCounter : ScoreCounter + private class MatchScoreCounter : CommaSeparatedScoreCounter { private OsuSpriteText displayedSpriteText; diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index e4ec45c00e..7e7c719152 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; @@ -88,7 +86,7 @@ namespace osu.Game.Tournament.Screens.Gameplay }, } }, - scoreDisplay = new MatchScoreDisplay + scoreDisplay = new TournamentMatchScoreDisplay { Y = -147, Anchor = Anchor.BottomCentre, @@ -126,19 +124,8 @@ namespace osu.Game.Tournament.Screens.Gameplay } }); - State.BindTo(ipc.State); - State.BindValueChanged(stateChanged, true); - ladder.ChromaKeyWidth.BindValueChanged(width => chroma.Width = width.NewValue, true); - currentMatch.BindValueChanged(m => - { - warmup.Value = m.NewValue.Team1Score.Value + m.NewValue.Team2Score.Value == 0; - scheduledOperation?.Cancel(); - }); - - currentMatch.BindTo(ladder.CurrentMatch); - warmup.BindValueChanged(w => { warmupButton.Alpha = !w.NewValue ? 0.5f : 1; @@ -146,8 +133,27 @@ namespace osu.Game.Tournament.Screens.Gameplay }, true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + State.BindTo(ipc.State); + State.BindValueChanged(stateChanged, 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; + private TournamentMatchScoreDisplay scoreDisplay; private TourneyState lastState; private MatchHeader header; @@ -158,12 +164,12 @@ namespace osu.Game.Tournament.Screens.Gameplay { if (state.NewValue == TourneyState.Ranking) { - if (warmup.Value) return; + if (warmup.Value || CurrentMatch.Value == null) 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 +178,7 @@ namespace osu.Game.Tournament.Screens.Gameplay { chat?.Contract(); - using (BeginDelayedSequence(300, true)) + using (BeginDelayedSequence(300)) { scoreDisplay.FadeIn(100); SongBar.Expanded = true; @@ -198,9 +204,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..1e3c550323 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) @@ -152,15 +147,15 @@ namespace osu.Game.Tournament.Screens.MapPool if (map != null) { - if (e.Button == MouseButton.Left && map.Beatmap.OnlineBeatmapID != null) - addForBeatmap(map.Beatmap.OnlineBeatmapID.Value); + if (e.Button == MouseButton.Left && map.BeatmapInfo.OnlineBeatmapID != null) + addForBeatmap(map.BeatmapInfo.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.BeatmapInfo.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/SetupScreen.cs b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs index 5d8f0405ca..f6d28c15e0 100644 --- a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs @@ -1,14 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Drawing; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; using osu.Game.Overlays; @@ -131,25 +129,5 @@ namespace osu.Game.Tournament.Screens.Setup resolution.Value = $"{ScreenSpaceDrawQuad.Width:N0}x{ScreenSpaceDrawQuad.Height:N0}"; } - - public class LabelledDropdown : LabelledComponent, T> - { - public LabelledDropdown() - : base(true) - { - } - - public IEnumerable Items - { - get => Component.Items; - set => Component.Items = value; - } - - protected override OsuDropdown CreateComponent() => new OsuDropdown - { - RelativeSizeAxes = Axes.X, - Width = 0.5f, - }; - } } } 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..7a43fee013 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -26,8 +26,8 @@ namespace osu.Game.Tournament { public static ColourInfo GetTeamColour(TeamColour teamColour) => teamColour == TeamColour.Red ? COLOUR_RED : COLOUR_BLUE; - public static readonly Color4 COLOUR_RED = Color4Extensions.FromHex("#AA1414"); - public static readonly Color4 COLOUR_BLUE = Color4Extensions.FromHex("#1462AA"); + public static readonly Color4 COLOUR_RED = new OsuColour().TeamColourRed; + public static readonly Color4 COLOUR_BLUE = new OsuColour().TeamColourBlue; public static readonly Color4 ELEMENT_BACKGROUND_COLOUR = Color4Extensions.FromHex("#fff"); public static readonly Color4 ELEMENT_FOREGROUND_COLOUR = Color4Extensions.FromHex("#000"); @@ -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.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 92eb7ac713..bdf7269c83 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -66,7 +66,9 @@ namespace osu.Game.Tournament } ladder ??= new LadderInfo(); - ladder.Ruleset.Value ??= RulesetStore.AvailableRulesets.First(); + + ladder.Ruleset.Value = RulesetStore.GetRuleset(ladder.Ruleset.Value?.ShortName) + ?? RulesetStore.AvailableRulesets.First(); bool addedInfo = false; @@ -180,7 +182,7 @@ namespace osu.Game.Tournament { var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = b.ID }); API.Perform(req); - b.BeatmapInfo = req.Result?.ToBeatmap(RulesetStore); + b.BeatmapInfo = req.Response?.ToBeatmapInfo(RulesetStore); addedInfo = true; } @@ -201,7 +203,7 @@ namespace osu.Game.Tournament { var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = b.ID }); req.Perform(API); - b.BeatmapInfo = req.Result?.ToBeatmap(RulesetStore); + b.BeatmapInfo = req.Response?.ToBeatmapInfo(RulesetStore); addedInfo = true; } diff --git a/osu.Game/.editorconfig b/osu.Game/.editorconfig new file mode 100644 index 0000000000..4107d1bb35 --- /dev/null +++ b/osu.Game/.editorconfig @@ -0,0 +1,3 @@ +[*.cs] +dotnet_diagnostic.OLOC001.prefix_namespace = osu.Game.Resources.Localisation +dotnet_diagnostic.OLOC001.license_header = // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.\n// See the LICENCE file in the repository root for full licence text. \ No newline at end of file diff --git a/osu.Game/Audio/Effects/AudioFilter.cs b/osu.Game/Audio/Effects/AudioFilter.cs new file mode 100644 index 0000000000..d2a39e9db7 --- /dev/null +++ b/osu.Game/Audio/Effects/AudioFilter.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.Diagnostics; +using ManagedBass.Fx; +using osu.Framework.Audio.Mixing; +using osu.Framework.Graphics; + +namespace osu.Game.Audio.Effects +{ + public class AudioFilter : Component, ITransformableFilter + { + /// + /// The maximum cutoff frequency that can be used with a low-pass filter. + /// This is equal to nyquist - 1hz. + /// + public const int MAX_LOWPASS_CUTOFF = 22049; // nyquist - 1hz + + private readonly AudioMixer mixer; + private readonly BQFParameters filter; + private readonly BQFType type; + + private bool isAttached; + + private int cutoff; + + /// + /// The cutoff frequency of this filter. + /// + public int Cutoff + { + get => cutoff; + set + { + if (value == cutoff) + return; + + cutoff = value; + updateFilter(cutoff); + } + } + + /// + /// A Component that implements a BASS FX BiQuad Filter Effect. + /// + /// The mixer this effect should be applied to. + /// The type of filter (e.g. LowPass, HighPass, etc) + public AudioFilter(AudioMixer mixer, BQFType type = BQFType.LowPass) + { + this.mixer = mixer; + this.type = type; + + filter = new BQFParameters + { + lFilter = type, + fBandwidth = 0, + // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0) + fQ = 0.7f + }; + + Cutoff = getInitialCutoff(type); + } + + private int getInitialCutoff(BQFType type) + { + switch (type) + { + case BQFType.HighPass: + return 1; + + case BQFType.LowPass: + return MAX_LOWPASS_CUTOFF; + + default: + return 500; // A default that should ensure audio remains audible for other filters. + } + } + + private void updateFilter(int newValue) + { + switch (type) + { + case BQFType.LowPass: + // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz. + if (newValue >= MAX_LOWPASS_CUTOFF) + { + ensureDetached(); + return; + } + + break; + + // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz. + case BQFType.HighPass: + if (newValue <= 1) + { + ensureDetached(); + return; + } + + break; + } + + ensureAttached(); + + var filterIndex = mixer.Effects.IndexOf(filter); + + if (filterIndex < 0) return; + + if (mixer.Effects[filterIndex] is BQFParameters existingFilter) + { + existingFilter.fCenter = newValue; + + // required to update effect with new parameters. + mixer.Effects[filterIndex] = existingFilter; + } + } + + private void ensureAttached() + { + if (isAttached) + return; + + Debug.Assert(!mixer.Effects.Contains(filter)); + mixer.Effects.Add(filter); + isAttached = true; + } + + private void ensureDetached() + { + if (!isAttached) + return; + + Debug.Assert(mixer.Effects.Contains(filter)); + mixer.Effects.Remove(filter); + isAttached = false; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + ensureDetached(); + } + } +} diff --git a/osu.Game/Audio/Effects/ITransformableFilter.cs b/osu.Game/Audio/Effects/ITransformableFilter.cs new file mode 100644 index 0000000000..fb6a924f68 --- /dev/null +++ b/osu.Game/Audio/Effects/ITransformableFilter.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; + +namespace osu.Game.Audio.Effects +{ + public interface ITransformableFilter + { + /// + /// The filter cutoff. + /// + int Cutoff { get; set; } + } + + public static class FilterableAudioComponentExtensions + { + /// + /// Smoothly adjusts filter cutoff over time. + /// + /// A to which further transforms can be added. + public static TransformSequence CutoffTo(this T component, int newCutoff, double duration = 0, Easing easing = Easing.None) + where T : class, ITransformableFilter, IDrawable + => component.CutoffTo(newCutoff, duration, new DefaultEasingFunction(easing)); + + /// + /// Smoothly adjusts filter cutoff over time. + /// + /// A to which further transforms can be added. + public static TransformSequence CutoffTo(this TransformSequence sequence, int newCutoff, double duration = 0, Easing easing = Easing.None) + where T : class, ITransformableFilter, IDrawable + => sequence.CutoffTo(newCutoff, duration, new DefaultEasingFunction(easing)); + + /// + /// Smoothly adjusts filter cutoff over time. + /// + /// A to which further transforms can be added. + public static TransformSequence CutoffTo(this T component, int newCutoff, double duration, TEasing easing) + where T : class, ITransformableFilter, IDrawable + where TEasing : IEasingFunction + => component.TransformTo(nameof(component.Cutoff), newCutoff, duration, easing); + + /// + /// Smoothly adjusts filter cutoff over time. + /// + /// A to which further transforms can be added. + public static TransformSequence CutoffTo(this TransformSequence sequence, int newCutoff, double duration, TEasing easing) + where T : class, ITransformableFilter, IDrawable + where TEasing : IEasingFunction + => sequence.Append(o => o.TransformTo(nameof(o.Cutoff), newCutoff, duration, easing)); + } +} diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index d88fd1e62b..1de9e1561f 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -7,6 +7,7 @@ using System.IO; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Audio.Mixing; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -30,11 +31,11 @@ namespace osu.Game.Audio private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(OsuGameBase.GLOBAL_TRACK_VOLUME_ADJUST); [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audioManager) { // this is a temporary solution to get around muting ourselves. // todo: update this once we have a BackgroundTrackManager or similar. - trackStore = new PreviewTrackStore(new OnlineStore()); + trackStore = new PreviewTrackStore(audioManager.TrackMixer, new OnlineStore()); audio.AddItem(trackStore); trackStore.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); @@ -118,10 +119,12 @@ namespace osu.Game.Audio private class PreviewTrackStore : AudioCollectionManager, ITrackStore { + private readonly AudioMixer defaultMixer; private readonly IResourceStore store; - internal PreviewTrackStore(IResourceStore store) + internal PreviewTrackStore(AudioMixer defaultMixer, IResourceStore store) { + this.defaultMixer = defaultMixer; this.store = store; } @@ -145,8 +148,12 @@ namespace osu.Game.Audio if (dataStream == null) return null; + // Todo: This is quite unsafe. TrackBass shouldn't be exposed as public. Track track = new TrackBass(dataStream); + + defaultMixer.Add(track); AddItem(track); + return track; } diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index e5b6a4bc44..b2211e26cf 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -18,17 +18,48 @@ namespace osu.Game.Beatmaps public class Beatmap : IBeatmap where T : HitObject { - public BeatmapInfo BeatmapInfo { get; set; } = new BeatmapInfo + private BeatmapDifficulty difficulty = new BeatmapDifficulty(); + + public BeatmapDifficulty Difficulty { - Metadata = new BeatmapMetadata + get => difficulty; + set { - Artist = @"Unknown", - Title = @"Unknown", - AuthorString = @"Unknown Creator", - }, - Version = @"Normal", - BaseDifficulty = new BeatmapDifficulty() - }; + difficulty = value; + + if (beatmapInfo != null) + beatmapInfo.BaseDifficulty = difficulty.Clone(); + } + } + + private BeatmapInfo beatmapInfo; + + public BeatmapInfo BeatmapInfo + { + get => beatmapInfo; + set + { + beatmapInfo = value; + + if (beatmapInfo?.BaseDifficulty != null) + Difficulty = beatmapInfo.BaseDifficulty.Clone(); + } + } + + public Beatmap() + { + beatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = @"Unknown", + Title = @"Unknown", + AuthorString = @"Unknown Creator", + }, + Version = @"Normal", + BaseDifficulty = Difficulty, + }; + } [JsonIgnore] public BeatmapMetadata Metadata => BeatmapInfo?.Metadata ?? BeatmapInfo?.BeatmapSet?.Metadata; diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index f3434c5153..627e54c803 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -40,7 +40,13 @@ namespace osu.Game.Beatmaps public IBeatmap Convert(CancellationToken cancellationToken = default) { // We always operate on a clone of the original beatmap, to not modify it game-wide - return ConvertBeatmap(Beatmap.Clone(), cancellationToken); + var original = Beatmap.Clone(); + + // Shallow clone isn't enough to ensure we don't mutate beatmap info unexpectedly. + // Can potentially be removed after `Beatmap.Difficulty` doesn't save back to `Beatmap.BeatmapInfo`. + original.BeatmapInfo = original.BeatmapInfo.Clone(); + + return ConvertBeatmap(original, cancellationToken); } /// diff --git a/osu.Game/Beatmaps/BeatmapDifficulty.cs b/osu.Game/Beatmaps/BeatmapDifficulty.cs index c56fec67aa..dfd21469fa 100644 --- a/osu.Game/Beatmaps/BeatmapDifficulty.cs +++ b/osu.Game/Beatmaps/BeatmapDifficulty.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.Game.Database; namespace osu.Game.Beatmaps { - public class BeatmapDifficulty : IHasPrimaryKey + public class BeatmapDifficulty : IHasPrimaryKey, IBeatmapDifficultyInfo { /// /// The default value used for all difficulty settings except and . @@ -20,6 +21,15 @@ namespace osu.Game.Beatmaps private float? approachRate; + public BeatmapDifficulty() + { + } + + public BeatmapDifficulty(IBeatmapDifficultyInfo source) + { + CopyFrom(source); + } + public float ApproachRate { get => approachRate ?? OverallDifficulty; @@ -32,48 +42,33 @@ namespace osu.Game.Beatmaps /// /// Returns a shallow-clone of this . /// - public BeatmapDifficulty Clone() => (BeatmapDifficulty)MemberwiseClone(); - - /// - /// Maps a difficulty value [0, 10] to a two-piece linear range of values. - /// - /// The difficulty value to be mapped. - /// Minimum of the resulting range which will be achieved by a difficulty value of 0. - /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. - /// Maximum of the resulting range which will be achieved by a difficulty value of 10. - /// Value to which the difficulty value maps in the specified range. - public static double DifficultyRange(double difficulty, double min, double mid, double max) + public BeatmapDifficulty Clone() { - if (difficulty > 5) - return mid + (max - mid) * (difficulty - 5) / 5; - if (difficulty < 5) - return mid - (mid - min) * (5 - difficulty) / 5; - - return mid; + var diff = (BeatmapDifficulty)Activator.CreateInstance(GetType()); + CopyTo(diff); + return diff; } - /// - /// Maps a difficulty value [0, 10] to a two-piece linear range of values. - /// - /// The difficulty value to be mapped. - /// The values that define the two linear ranges. - /// - /// - /// od0 - /// Minimum of the resulting range which will be achieved by a difficulty value of 0. - /// - /// - /// od5 - /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. - /// - /// - /// od10 - /// Maximum of the resulting range which will be achieved by a difficulty value of 10. - /// - /// - /// - /// Value to which the difficulty value maps in the specified range. - public static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range) - => DifficultyRange(difficulty, range.od0, range.od5, range.od10); + public virtual void CopyFrom(IBeatmapDifficultyInfo other) + { + ApproachRate = other.ApproachRate; + DrainRate = other.DrainRate; + CircleSize = other.CircleSize; + OverallDifficulty = other.OverallDifficulty; + + SliderMultiplier = other.SliderMultiplier; + SliderTickRate = other.SliderTickRate; + } + + public virtual void CopyTo(BeatmapDifficulty other) + { + other.ApproachRate = ApproachRate; + other.DrainRate = DrainRate; + other.CircleSize = CircleSize; + other.OverallDifficulty = OverallDifficulty; + + other.SliderMultiplier = SliderMultiplier; + other.SliderTickRate = SliderTickRate; + } } } diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 6ed623d0c0..3777365088 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -14,8 +14,10 @@ using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Threading; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -56,12 +58,28 @@ namespace osu.Game.Beatmaps [Resolved] private Bindable> currentMods { get; set; } + private ModSettingChangeTracker modSettingChangeTracker; + private ScheduledDelegate debouncedModSettingsChange; + protected override void LoadComplete() { base.LoadComplete(); currentRuleset.BindValueChanged(_ => updateTrackedBindables()); - currentMods.BindValueChanged(_ => updateTrackedBindables(), true); + + currentMods.BindValueChanged(mods => + { + modSettingChangeTracker?.Dispose(); + + updateTrackedBindables(); + + modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); + modSettingChangeTracker.SettingChanged += _ => + { + debouncedModSettingsChange?.Cancel(); + debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, 100); + }; + }, true); } /// @@ -84,7 +102,7 @@ namespace osu.Game.Beatmaps /// Retrieves a bindable containing the star difficulty of a with a given and combination. /// /// - /// The bindable will not update to follow the currently-selected ruleset and mods. + /// The bindable will not update to follow the currently-selected ruleset and mods or its settings. /// /// The to get the difficulty of. /// The to get the difficulty with. If null, the 's ruleset is used. @@ -130,6 +148,14 @@ namespace osu.Game.Beatmaps }, token, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); } + public Task> GetTimedDifficultyAttributesAsync(WorkingBeatmap beatmap, Ruleset ruleset, Mod[] mods, CancellationToken token = default) + { + return Task.Factory.StartNew(() => ruleset.CreateDifficultyCalculator(beatmap).CalculateTimed(mods), + token, + TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, + updateScheduler); + } + /// /// Retrieves the that describes a star rating. /// @@ -225,7 +251,7 @@ namespace osu.Game.Beatmaps { // GetDifficultyAsync will fall back to existing data from BeatmapInfo if not locally available // (contrary to GetAsync) - GetDifficultyAsync(bindable.Beatmap, rulesetInfo, mods, cancellationToken) + GetDifficultyAsync(bindable.BeatmapInfo, rulesetInfo, mods, cancellationToken) .ContinueWith(t => { // We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events. @@ -245,7 +271,7 @@ namespace osu.Game.Beatmaps private StarDifficulty computeDifficulty(in DifficultyCacheLookup key) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. - var beatmapInfo = key.Beatmap; + var beatmapInfo = key.BeatmapInfo; var rulesetInfo = key.Ruleset; try @@ -253,7 +279,7 @@ namespace osu.Game.Beatmaps var ruleset = rulesetInfo.CreateInstance(); Debug.Assert(ruleset != null); - var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(key.Beatmap)); + var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo)); var attributes = calculator.Calculate(key.OrderedMods); return new StarDifficulty(attributes); @@ -275,39 +301,41 @@ namespace osu.Game.Beatmaps { base.Dispose(isDisposing); + modSettingChangeTracker?.Dispose(); + cancelTrackedBindableUpdate(); updateScheduler?.Dispose(); } public readonly struct DifficultyCacheLookup : IEquatable { - public readonly BeatmapInfo Beatmap; + public readonly BeatmapInfo BeatmapInfo; public readonly RulesetInfo Ruleset; public readonly Mod[] OrderedMods; - public DifficultyCacheLookup([NotNull] BeatmapInfo beatmap, [CanBeNull] RulesetInfo ruleset, IEnumerable mods) + public DifficultyCacheLookup([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo ruleset, IEnumerable mods) { - Beatmap = beatmap; + BeatmapInfo = beatmapInfo; // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. - Ruleset = ruleset ?? Beatmap.Ruleset; - OrderedMods = mods?.OrderBy(m => m.Acronym).ToArray() ?? Array.Empty(); + Ruleset = ruleset ?? BeatmapInfo.Ruleset; + OrderedMods = mods?.OrderBy(m => m.Acronym).Select(mod => mod.DeepClone()).ToArray() ?? Array.Empty(); } public bool Equals(DifficultyCacheLookup other) - => Beatmap.ID == other.Beatmap.ID + => BeatmapInfo.ID == other.BeatmapInfo.ID && Ruleset.ID == other.Ruleset.ID - && OrderedMods.Select(m => m.Acronym).SequenceEqual(other.OrderedMods.Select(m => m.Acronym)); + && OrderedMods.SequenceEqual(other.OrderedMods); public override int GetHashCode() { var hashCode = new HashCode(); - hashCode.Add(Beatmap.ID); + hashCode.Add(BeatmapInfo.ID); hashCode.Add(Ruleset.ID); foreach (var mod in OrderedMods) - hashCode.Add(mod.Acronym); + hashCode.Add(mod); return hashCode.ToHashCode(); } @@ -315,12 +343,12 @@ namespace osu.Game.Beatmaps private class BindableStarDifficulty : Bindable { - public readonly BeatmapInfo Beatmap; + public readonly BeatmapInfo BeatmapInfo; public readonly CancellationToken CancellationToken; - public BindableStarDifficulty(BeatmapInfo beatmap, CancellationToken cancellationToken) + public BindableStarDifficulty(BeatmapInfo beatmapInfo, CancellationToken cancellationToken) { - Beatmap = beatmap; + BeatmapInfo = beatmapInfo; CancellationToken = cancellationToken; } } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 36cb97e8d7..ac5b5d7a8a 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -7,10 +7,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using Newtonsoft.Json; -using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Database; -using osu.Game.IO.Serialization; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -18,7 +16,7 @@ namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] [Serializable] - public class BeatmapInfo : IEquatable, IJsonSerializable, IHasPrimaryKey + public class BeatmapInfo : IEquatable, IHasPrimaryKey, IBeatmapInfo { public int ID { get; set; } @@ -83,7 +81,6 @@ namespace osu.Game.Beatmaps // General public double AudioLeadIn { get; set; } - public bool Countdown { get; set; } = true; public float StackLeniency { get; set; } = 0.7f; public bool SpecialStyle { get; set; } @@ -95,6 +92,19 @@ namespace osu.Game.Beatmaps public bool WidescreenStoryboard { get; set; } public bool EpilepsyWarning { get; set; } + /// + /// Whether or not sound samples should change rate when playing with speed-changing mods. + /// TODO: only read/write supported for now, requires implementation in gameplay. + /// + public bool SamplesMatchPlaybackRate { get; set; } + + public CountdownType Countdown { get; set; } = CountdownType.Normal; + + /// + /// The number of beats to move the countdown backwards (compared to its default location). + /// + public int CountdownOffset { get; set; } + // Editor // This bookmarks stuff is necessary because DB doesn't know how to store int[] [JsonIgnore] @@ -141,18 +151,7 @@ namespace osu.Game.Beatmaps [JsonIgnore] public DifficultyRating DifficultyRating => BeatmapDifficultyCache.GetDifficultyRating(StarDifficulty); - public string[] SearchableTerms => new[] - { - Version - }.Concat(Metadata?.SearchableTerms ?? Enumerable.Empty()).Where(s => !string.IsNullOrEmpty(s)).ToArray(); - - public override string ToString() => $"{Metadata ?? BeatmapSet?.Metadata} {versionString}".Trim(); - - public RomanisableString ToRomanisableString() - { - var metadata = (Metadata ?? BeatmapSet?.Metadata)?.ToRomanisableString() ?? new RomanisableString(null, null); - return new RomanisableString($"{metadata.GetPreferred(true)} {versionString}".Trim(), $"{metadata.GetPreferred(false)} {versionString}".Trim()); - } + public override string ToString() => this.GetDisplayTitle(); public bool Equals(BeatmapInfo other) { @@ -176,5 +175,22 @@ namespace osu.Game.Beatmaps /// Returns a shallow-clone of this . /// public BeatmapInfo Clone() => (BeatmapInfo)MemberwiseClone(); + + #region Implementation of IHasOnlineID + + public int? OnlineID => OnlineBeatmapID; + + #endregion + + #region Implementation of IBeatmapInfo + + string IBeatmapInfo.DifficultyName => Version; + IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; + IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => BaseDifficulty; + IBeatmapSetInfo IBeatmapInfo.BeatmapSet => BeatmapSet; + IRulesetInfo IBeatmapInfo.Ruleset => Ruleset; + double IBeatmapInfo.StarRating => StarDifficulty; + + #endregion } } diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs new file mode 100644 index 0000000000..eba19ac1a1 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.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.Linq; +using osu.Framework.Localisation; + +namespace osu.Game.Beatmaps +{ + public static class BeatmapInfoExtensions + { + /// + /// A user-presentable display title representing this beatmap. + /// + public static string GetDisplayTitle(this IBeatmapInfo beatmapInfo) => $"{getClosestMetadata(beatmapInfo)} {getVersionString(beatmapInfo)}".Trim(); + + /// + /// A user-presentable display title representing this beatmap, with localisation handling for potentially romanisable fields. + /// + public static RomanisableString GetDisplayTitleRomanisable(this IBeatmapInfo beatmapInfo) + { + var metadata = getClosestMetadata(beatmapInfo).GetDisplayTitleRomanisable(); + var versionString = getVersionString(beatmapInfo); + + return new RomanisableString($"{metadata.GetPreferred(true)} {versionString}".Trim(), $"{metadata.GetPreferred(false)} {versionString}".Trim()); + } + + public static string[] GetSearchableTerms(this IBeatmapInfo beatmapInfo) => new[] + { + beatmapInfo.DifficultyName + }.Concat(getClosestMetadata(beatmapInfo).GetSearchableTerms()).Where(s => !string.IsNullOrEmpty(s)).ToArray(); + + private static string getVersionString(IBeatmapInfo beatmapInfo) => string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? string.Empty : $"[{beatmapInfo.DifficultyName}]"; + + // temporary helper methods until we figure which metadata should be where. + private static IBeatmapMetadataInfo getClosestMetadata(IBeatmapInfo beatmapInfo) => + beatmapInfo.Metadata ?? beatmapInfo.BeatmapSet?.Metadata ?? new BeatmapMetadata(); + } +} diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 14bddb6319..14175f251b 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -6,111 +6,68 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Linq.Expressions; -using System.Text; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; using osu.Framework.Audio; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; -using osu.Framework.Lists; -using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Statistics; using osu.Framework.Testing; -using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; -using osu.Game.Rulesets.Objects; using osu.Game.Skinning; using osu.Game.Users; -using Decoder = osu.Game.Beatmaps.Formats.Decoder; namespace osu.Game.Beatmaps { /// - /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. + /// Handles general operations related to global beatmap management. /// [ExcludeFromDynamicCompile] - public partial class BeatmapManager : DownloadableArchiveModelManager, IDisposable, IBeatmapResourceProvider + public class BeatmapManager : IModelDownloader, IModelManager, IModelFileManager, ICanAcceptFiles, IWorkingBeatmapCache, IDisposable { - /// - /// Fired when a single difficulty has been hidden. - /// - public IBindable> BeatmapHidden => beatmapHidden; + private readonly BeatmapModelManager beatmapModelManager; + private readonly BeatmapModelDownloader beatmapModelDownloader; - private readonly Bindable> beatmapHidden = new Bindable>(); - - /// - /// Fired when a single difficulty has been restored. - /// - public IBindable> BeatmapRestored => beatmapRestored; - - private readonly Bindable> beatmapRestored = new Bindable>(); - - /// - /// A default representation of a WorkingBeatmap to use when no beatmap is available. - /// - public readonly WorkingBeatmap DefaultBeatmap; - - public override IEnumerable HandledExtensions => new[] { ".osz" }; - - protected override string[] HashableFileTypes => new[] { ".osu" }; - - protected override string ImportFromStablePath => "."; - - protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage(); - - private readonly RulesetStore rulesets; - private readonly BeatmapStore beatmaps; - private readonly AudioManager audioManager; - private readonly IResourceStore resources; - private readonly LargeTextureStore largeTextureStore; - private readonly ITrackStore trackStore; - - [CanBeNull] - private readonly GameHost host; - - [CanBeNull] - private readonly BeatmapOnlineLookupQueue onlineLookupQueue; + private readonly WorkingBeatmapCache workingBeatmapCache; + private readonly BeatmapOnlineLookupQueue onlineBeatmapLookupQueue; public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false) - : base(storage, contextFactory, api, new BeatmapStore(contextFactory), host) { - this.rulesets = rulesets; - this.audioManager = audioManager; - this.resources = resources; - this.host = host; + beatmapModelManager = CreateBeatmapModelManager(storage, contextFactory, rulesets, api, host); + beatmapModelDownloader = CreateBeatmapModelDownloader(beatmapModelManager, api, host); + workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, resources, new FileStore(contextFactory, storage).Store, defaultBeatmap, host); - DefaultBeatmap = defaultBeatmap; - - beatmaps = (BeatmapStore)ModelStore; - beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference(b); - beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference(b); - beatmaps.ItemRemoved += removeWorkingCache; - beatmaps.ItemUpdated += removeWorkingCache; + workingBeatmapCache.BeatmapManager = beatmapModelManager; + beatmapModelManager.WorkingBeatmapCache = workingBeatmapCache; if (performOnlineLookups) - onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage); - - largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store)); - trackStore = audioManager.GetTrackStore(Files.Store); + { + onlineBeatmapLookupQueue = new BeatmapOnlineLookupQueue(api, storage); + beatmapModelManager.OnlineLookupQueue = onlineBeatmapLookupQueue; + } } - protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) => - new DownloadBeatmapSetRequest(set, minimiseDownloadSize); + protected virtual BeatmapModelDownloader CreateBeatmapModelDownloader(IBeatmapModelManager modelManager, IAPIProvider api, GameHost host) + { + return new BeatmapModelDownloader(modelManager, api, host); + } - protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz"; + protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap defaultBeatmap, GameHost host) => + new WorkingBeatmapCache(audioManager, resources, storage, defaultBeatmap, host); + protected virtual BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host) => + new BeatmapModelManager(storage, contextFactory, rulesets, host); + + /// + /// Create a new . + /// public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user) { var metadata = new BeatmapMetadata @@ -128,116 +85,28 @@ namespace osu.Game.Beatmaps BaseDifficulty = new BeatmapDifficulty(), Ruleset = ruleset, Metadata = metadata, + WidescreenStoryboard = true, + SamplesMatchPlaybackRate = true, } } }; - var working = Import(set).Result; - return GetWorkingBeatmap(working.Beatmaps.First()); + var imported = beatmapModelManager.Import(set).Result.Value; + + return GetWorkingBeatmap(imported.Beatmaps.First()); } - protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default) - { - if (archive != null) - beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files); - - foreach (BeatmapInfo b in beatmapSet.Beatmaps) - { - // remove metadata from difficulties where it matches the set - if (beatmapSet.Metadata.Equals(b.Metadata)) - b.Metadata = null; - - b.BeatmapSet = beatmapSet; - } - - validateOnlineIds(beatmapSet); - - bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0); - - if (onlineLookupQueue != null) - await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false); - - // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID. - if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0)) - { - if (beatmapSet.OnlineBeatmapSetID != null) - { - beatmapSet.OnlineBeatmapSetID = null; - LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs"); - } - } - } - - protected override void PreImport(BeatmapSetInfo beatmapSet) - { - if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null)) - throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}."); - - // check if a set already exists with the same online id, delete if it does. - if (beatmapSet.OnlineBeatmapSetID != null) - { - var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID); - - if (existingOnlineId != null) - { - Delete(existingOnlineId); - - // 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."); - } - } - } - - private void validateOnlineIds(BeatmapSetInfo beatmapSet) - { - var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList(); - - // ensure all IDs are unique - if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1)) - { - LogForModel(beatmapSet, "Found non-unique IDs, resetting..."); - resetIds(); - return; - } - - // find any existing beatmaps in the database that have matching online ids - var existingBeatmaps = QueryBeatmaps(b => beatmapIds.Contains(b.OnlineBeatmapID)).ToList(); - - if (existingBeatmaps.Count > 0) - { - // reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set. - // we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted. - var existing = CheckForExisting(beatmapSet); - - if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b))) - { - LogForModel(beatmapSet, "Found existing import with IDs already, resetting..."); - resetIds(); - } - } - - void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null); - } - - protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable items) - => base.CheckLocalAvailability(model, items) - || (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID)); + #region Delegation to BeatmapModelManager (methods which previously existed locally). /// - /// Delete a beatmap difficulty. + /// Fired when a single difficulty has been hidden. /// - /// The beatmap difficulty to hide. - public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap); + public IBindable> BeatmapHidden => beatmapModelManager.BeatmapHidden; /// - /// Restore a beatmap difficulty. + /// Fired when a single difficulty has been restored. /// - /// The beatmap difficulty to restore. - public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap); + public IBindable> BeatmapRestored => beatmapModelManager.BeatmapRestored; /// /// Saves an file against a given . @@ -245,109 +114,13 @@ namespace osu.Game.Beatmaps /// The to save the content against. The file referenced by will be replaced. /// The content to write. /// The beatmap content to write, null if to be omitted. - public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) - { - var setInfo = info.BeatmapSet; - - using (var stream = new MemoryStream()) - { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw); - - stream.Seek(0, SeekOrigin.Begin); - - using (ContextFactory.GetForWrite()) - { - var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID); - var metadata = beatmapInfo.Metadata ?? setInfo.Metadata; - - // grab the original file (or create a new one if not found). - var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo(); - - // metadata may have changed; update the path with the standard format. - beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu"; - beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); - - // update existing or populate new file's filename. - fileInfo.Filename = beatmapInfo.Path; - - stream.Seek(0, SeekOrigin.Begin); - ReplaceFile(setInfo, fileInfo, stream); - } - } - - removeWorkingCache(info); - } - - private readonly WeakList workingCache = new WeakList(); - - /// - /// Retrieve a instance for the provided - /// - /// The beatmap to lookup. - /// A instance correlating to the provided . - public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo) - { - // if there are no files, presume the full beatmap info has not yet been fetched from the database. - if (beatmapInfo?.BeatmapSet?.Files.Count == 0) - { - int lookupId = beatmapInfo.ID; - beatmapInfo = QueryBeatmap(b => b.ID == lookupId); - } - - if (beatmapInfo?.BeatmapSet == null) - return DefaultBeatmap; - - lock (workingCache) - { - var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID); - if (working != null) - return working; - - beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata; - - workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this)); - - // best effort; may be higher than expected. - GlobalStatistics.Get(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count(); - - return working; - } - } - - /// - /// Perform a lookup query on available s. - /// - /// The query. - /// 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.CanReuseExisting(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)) - return false; - - var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i); - var importIds = import.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i); - - // force re-import if we are not in a sane state. - return existing.OnlineBeatmapSetID == import.OnlineBeatmapSetID && existingIds.SequenceEqual(importIds); - } + public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) => beatmapModelManager.Save(info, beatmapContent, beatmapSkin); /// /// Returns a list of all usable s. /// /// A list of available . - public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => - GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList(); + public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSets(includes, includeProtected); /// /// Returns a list of all usable s. Note that files are not populated. @@ -355,208 +128,215 @@ namespace osu.Game.Beatmaps /// The level of detail to include in the returned objects. /// Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases. /// A list of available . - public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) - { - IQueryable queryable; - - switch (includes) - { - case IncludedDetails.Minimal: - queryable = beatmaps.BeatmapSetsOverview; - break; - - case IncludedDetails.AllButFiles: - queryable = beatmaps.BeatmapSetsWithoutFiles; - break; - - default: - queryable = beatmaps.ConsumableItems; - break; - } - - // AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY - // clause which causes queries to take 5-10x longer. - // TODO: remove if upgrading to EF core 3.x. - return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected)); - } + public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSetsEnumerable(includes, includeProtected); /// /// Perform a lookup query on available s. /// /// The query. + /// The level of detail to include in the returned objects. /// Results from the provided query. - public IEnumerable QueryBeatmapSets(Expression> query) => beatmaps.ConsumableItems.AsNoTracking().Where(query); + public IEnumerable QueryBeatmapSets(Expression> query, IncludedDetails includes = IncludedDetails.All) => beatmapModelManager.QueryBeatmapSets(query, includes); + + /// + /// Perform a lookup query on available s. + /// + /// The query. + /// The first result for the provided query, or null if no results were found. + public BeatmapSetInfo QueryBeatmapSet(Expression> query) => beatmapModelManager.QueryBeatmapSet(query); + + /// + /// Perform a lookup query on available s. + /// + /// The query. + /// Results from the provided query. + public IQueryable QueryBeatmaps(Expression> query) => beatmapModelManager.QueryBeatmaps(query); /// /// Perform a lookup query on available s. /// /// The query. /// The first result for the provided query, or null if no results were found. - public BeatmapInfo QueryBeatmap(Expression> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query); + public BeatmapInfo QueryBeatmap(Expression> query) => beatmapModelManager.QueryBeatmap(query); /// - /// Perform a lookup query on available s. + /// A default representation of a WorkingBeatmap to use when no beatmap is available. /// - /// The query. - /// Results from the provided query. - public IQueryable QueryBeatmaps(Expression> query) => beatmaps.Beatmaps.AsNoTracking().Where(query); + public WorkingBeatmap DefaultBeatmap => workingBeatmapCache.DefaultBeatmap; - protected override string HumanisedModelName => "beatmap"; - - protected override BeatmapSetInfo CreateModel(ArchiveReader reader) + /// + /// Fired when a notification should be presented to the user. + /// + public Action PostNotification { - // let's make sure there are actually .osu files to import. - string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)); - - if (string.IsNullOrEmpty(mapName)) + set { - Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database); - return null; + beatmapModelManager.PostNotification = value; + beatmapModelDownloader.PostNotification = value; } - - Beatmap beatmap; - using (var stream = new LineBufferedReader(reader.GetStream(mapName))) - beatmap = Decoder.GetDecoder(stream).Decode(stream); - - return new BeatmapSetInfo - { - OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID, - Beatmaps = new List(), - Metadata = beatmap.Metadata, - DateAdded = DateTimeOffset.UtcNow - }; } /// - /// Create all required s for the provided archive. + /// Fired when the user requests to view the resulting import. /// - private List createBeatmapDifficulties(List files) - { - var beatmapInfos = new List(); + public Action>> PresentImport { set => beatmapModelManager.PostImport = value; } - foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) - { - using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath)) - using (var ms = new MemoryStream()) // we need a memory stream so we can seek - using (var sr = new LineBufferedReader(ms)) - { - raw.CopyTo(ms); - ms.Position = 0; + /// + /// Delete a beatmap difficulty. + /// + /// The beatmap difficulty to hide. + public void Hide(BeatmapInfo beatmapInfo) => beatmapModelManager.Hide(beatmapInfo); - var decoder = Decoder.GetDecoder(sr); - IBeatmap beatmap = decoder.Decode(sr); - - string hash = ms.ComputeSHA2Hash(); - - if (beatmapInfos.Any(b => b.Hash == hash)) - continue; - - beatmap.BeatmapInfo.Path = file.Filename; - beatmap.BeatmapInfo.Hash = hash; - beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash(); - - var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID); - beatmap.BeatmapInfo.Ruleset = ruleset; - - // TODO: this should be done in a better place once we actually need to dynamically update it. - beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0; - beatmap.BeatmapInfo.Length = calculateLength(beatmap); - beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength(); - - beatmapInfos.Add(beatmap.BeatmapInfo); - } - } - - return beatmapInfos; - } - - private double calculateLength(IBeatmap b) - { - if (!b.HitObjects.Any()) - return 0; - - var lastObject = b.HitObjects.Last(); - - //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). - double endTime = lastObject.GetEndTime(); - double startTime = b.HitObjects.First().StartTime; - - return endTime - startTime; - } - - private void removeWorkingCache(BeatmapSetInfo info) - { - if (info.Beatmaps == null) return; - - foreach (var b in info.Beatmaps) - removeWorkingCache(b); - } - - private void removeWorkingCache(BeatmapInfo info) - { - lock (workingCache) - { - var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID); - if (working != null) - workingCache.Remove(working); - } - } - - public void Dispose() - { - onlineLookupQueue?.Dispose(); - } - - #region IResourceStorageProvider - - TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; - ITrackStore IBeatmapResourceProvider.Tracks => trackStore; - AudioManager IStorageResourceProvider.AudioManager => audioManager; - IResourceStore IStorageResourceProvider.Files => Files.Store; - IResourceStore IStorageResourceProvider.Resources => resources; - IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore); + /// + /// Restore a beatmap difficulty. + /// + /// The beatmap difficulty to restore. + public void Restore(BeatmapInfo beatmapInfo) => beatmapModelManager.Restore(beatmapInfo); #endregion - /// - /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation. - /// - private class DummyConversionBeatmap : WorkingBeatmap + #region Implementation of IModelManager + + public bool IsAvailableLocally(BeatmapSetInfo model) { - private readonly IBeatmap beatmap; - - public DummyConversionBeatmap(IBeatmap beatmap) - : base(beatmap.BeatmapInfo, null) - { - this.beatmap = beatmap; - } - - protected override IBeatmap GetBeatmap() => beatmap; - protected override Texture GetBackground() => null; - protected override Track GetBeatmapTrack() => null; - protected override ISkin GetSkin() => null; - public override Stream GetStream(string storagePath) => null; + return beatmapModelManager.IsAvailableLocally(model); } - } - /// - /// The level of detail to include in database results. - /// - public enum IncludedDetails - { - /// - /// Only include beatmap difficulties and set level metadata. - /// - Minimal, + public IBindable> ItemUpdated => beatmapModelManager.ItemUpdated; - /// - /// Include all difficulties, rulesets, difficulty metadata but no files. - /// - AllButFiles, + public IBindable> ItemRemoved => beatmapModelManager.ItemRemoved; - /// - /// Include everything. - /// - All + public Task ImportFromStableAsync(StableStorage stableStorage) + { + return beatmapModelManager.ImportFromStableAsync(stableStorage); + } + + public void Export(BeatmapSetInfo item) + { + beatmapModelManager.Export(item); + } + + public void ExportModelTo(BeatmapSetInfo model, Stream outputStream) + { + beatmapModelManager.ExportModelTo(model, outputStream); + } + + public void Update(BeatmapSetInfo item) + { + beatmapModelManager.Update(item); + } + + public bool Delete(BeatmapSetInfo item) + { + return beatmapModelManager.Delete(item); + } + + public void Delete(List items, bool silent = false) + { + beatmapModelManager.Delete(items, silent); + } + + public void Undelete(List items, bool silent = false) + { + beatmapModelManager.Undelete(items, silent); + } + + public void Undelete(BeatmapSetInfo item) + { + beatmapModelManager.Undelete(item); + } + + #endregion + + #region Implementation of IModelDownloader + + public IBindable>> DownloadBegan => beatmapModelDownloader.DownloadBegan; + + public IBindable>> DownloadFailed => beatmapModelDownloader.DownloadFailed; + + public bool Download(BeatmapSetInfo model, bool minimiseDownloadSize = false) + { + return beatmapModelDownloader.Download(model, minimiseDownloadSize); + } + + public ArchiveDownloadRequest GetExistingDownload(BeatmapSetInfo model) + { + return beatmapModelDownloader.GetExistingDownload(model); + } + + #endregion + + #region Implementation of ICanAcceptFiles + + public Task Import(params string[] paths) + { + return beatmapModelManager.Import(paths); + } + + public Task Import(params ImportTask[] tasks) + { + return beatmapModelManager.Import(tasks); + } + + public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) + { + return beatmapModelManager.Import(notification, tasks); + } + + public Task> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return beatmapModelManager.Import(task, lowPriority, cancellationToken); + } + + public Task> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return beatmapModelManager.Import(archive, lowPriority, cancellationToken); + } + + public Task> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return beatmapModelManager.Import(item, archive, lowPriority, cancellationToken); + } + + public IEnumerable HandledExtensions => beatmapModelManager.HandledExtensions; + + #endregion + + #region Implementation of IWorkingBeatmapCache + + public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo importedBeatmap) => workingBeatmapCache.GetWorkingBeatmap(importedBeatmap); + + void IWorkingBeatmapCache.Invalidate(BeatmapSetInfo beatmapSetInfo) => workingBeatmapCache.Invalidate(beatmapSetInfo); + void IWorkingBeatmapCache.Invalidate(BeatmapInfo beatmapInfo) => workingBeatmapCache.Invalidate(beatmapInfo); + + #endregion + + #region Implementation of IModelFileManager + + public void ReplaceFile(BeatmapSetInfo model, BeatmapSetFileInfo file, Stream contents, string filename = null) + { + beatmapModelManager.ReplaceFile(model, file, contents, filename); + } + + public void DeleteFile(BeatmapSetInfo model, BeatmapSetFileInfo file) + { + beatmapModelManager.DeleteFile(model, file); + } + + public void AddFile(BeatmapSetInfo model, Stream contents, string filename) + { + beatmapModelManager.AddFile(model, contents, filename); + } + + #endregion + + #region Implementation of IDisposable + + public void Dispose() + { + onlineBeatmapLookupQueue?.Dispose(); + } + + #endregion } } diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs deleted file mode 100644 index 7824205257..0000000000 --- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs +++ /dev/null @@ -1,209 +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.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Data.Sqlite; -using osu.Framework.Development; -using osu.Framework.IO.Network; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Framework.Testing; -using osu.Framework.Threading; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using SharpCompress.Compressors; -using SharpCompress.Compressors.BZip2; - -namespace osu.Game.Beatmaps -{ - public partial class BeatmapManager - { - [ExcludeFromDynamicCompile] - private class BeatmapOnlineLookupQueue : IDisposable - { - private readonly IAPIProvider api; - private readonly Storage storage; - - private const int update_queue_request_concurrency = 4; - - private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue)); - - private FileWebRequest cacheDownloadRequest; - - private const string cache_database_name = "online.db"; - - public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage) - { - this.api = api; - this.storage = storage; - - // avoid downloading / using cache for unit tests. - if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name)) - prepareLocalCache(); - } - - public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) - { - return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); - } - - // todo: expose this when we need to do individual difficulty lookups. - protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken) - => Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); - - private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap) - { - if (checkLocalCache(set, beatmap)) - return; - - if (api?.State.Value != APIState.Online) - return; - - var req = new GetBeatmapRequest(beatmap); - - req.Failure += fail; - - try - { - // intentionally blocking to limit web request concurrency - api.Perform(req); - - var res = req.Result; - - if (res != null) - { - beatmap.Status = res.Status; - beatmap.BeatmapSet.Status = res.BeatmapSet.Status; - beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; - beatmap.OnlineBeatmapID = res.OnlineBeatmapID; - - if (beatmap.Metadata != null) - beatmap.Metadata.AuthorID = res.AuthorID; - - if (beatmap.BeatmapSet.Metadata != null) - beatmap.BeatmapSet.Metadata.AuthorID = res.AuthorID; - - LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); - } - } - catch (Exception e) - { - fail(e); - } - - void fail(Exception e) - { - beatmap.OnlineBeatmapID = null; - LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})"); - } - } - - private void prepareLocalCache() - { - string cacheFilePath = storage.GetFullPath(cache_database_name); - string compressedCacheFilePath = $"{cacheFilePath}.bz2"; - - cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}"); - - cacheDownloadRequest.Failed += ex => - { - File.Delete(compressedCacheFilePath); - File.Delete(cacheFilePath); - - Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database); - }; - - cacheDownloadRequest.Finished += () => - { - try - { - using (var stream = File.OpenRead(cacheDownloadRequest.Filename)) - using (var outStream = File.OpenWrite(cacheFilePath)) - using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false)) - bz2.CopyTo(outStream); - - // set to null on completion to allow lookups to begin using the new source - cacheDownloadRequest = null; - } - catch (Exception ex) - { - Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database); - File.Delete(cacheFilePath); - } - finally - { - File.Delete(compressedCacheFilePath); - } - }; - - cacheDownloadRequest.PerformAsync(); - } - - private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap) - { - // download is in progress (or was, and failed). - if (cacheDownloadRequest != null) - return false; - - // database is unavailable. - if (!storage.Exists(cache_database_name)) - return false; - - try - { - using (var db = new SqliteConnection(storage.GetDatabaseConnectionString("online"))) - { - db.Open(); - - using (var cmd = db.CreateCommand()) - { - cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path"; - - cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash)); - cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value)); - cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path)); - - using (var reader = cmd.ExecuteReader()) - { - if (reader.Read()) - { - var status = (BeatmapSetOnlineStatus)reader.GetByte(2); - - beatmap.Status = status; - beatmap.BeatmapSet.Status = status; - beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0); - beatmap.OnlineBeatmapID = reader.GetInt32(1); - - if (beatmap.Metadata != null) - beatmap.Metadata.AuthorID = reader.GetInt32(3); - - if (beatmap.BeatmapSet.Metadata != null) - beatmap.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3); - - LogForModel(set, $"Cached local retrieval for {beatmap}."); - return true; - } - } - } - } - } - catch (Exception ex) - { - LogForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}."); - } - - return false; - } - - public void Dispose() - { - cacheDownloadRequest?.Dispose(); - updateScheduler?.Dispose(); - } - } - } -} diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index bfc0236db3..711533e118 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -4,9 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; using Newtonsoft.Json; -using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Users; @@ -15,7 +13,7 @@ namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] [Serializable] - public class BeatmapMetadata : IEquatable, IHasPrimaryKey + public class BeatmapMetadata : IEquatable, IHasPrimaryKey, IBeatmapMetadataInfo { public int ID { get; set; } @@ -83,47 +81,13 @@ namespace osu.Game.Beatmaps public int PreviewTime { get; set; } public string AudioFile { get; set; } + public string BackgroundFile { get; set; } - public override string ToString() - { - string author = Author == null ? string.Empty : $"({Author})"; - return $"{Artist} - {Title} {author}".Trim(); - } + public bool Equals(BeatmapMetadata other) => ((IBeatmapMetadataInfo)this).Equals(other); - public RomanisableString ToRomanisableString() - { - string author = Author == null ? string.Empty : $"({Author})"; - return new RomanisableString($"{ArtistUnicode} - {TitleUnicode} {author}".Trim(), $"{Artist} - {Title} {author}".Trim()); - } + public override string ToString() => this.GetDisplayTitle(); - [JsonIgnore] - public string[] SearchableTerms => new[] - { - Author?.Username, - Artist, - ArtistUnicode, - Title, - TitleUnicode, - Source, - Tags - }.Where(s => !string.IsNullOrEmpty(s)).ToArray(); - - public bool Equals(BeatmapMetadata other) - { - if (other == null) - return false; - - return Title == other.Title - && TitleUnicode == other.TitleUnicode - && Artist == other.Artist - && ArtistUnicode == other.ArtistUnicode - && AuthorString == other.AuthorString - && Source == other.Source - && Tags == other.Tags - && PreviewTime == other.PreviewTime - && AudioFile == other.AudioFile - && BackgroundFile == other.BackgroundFile; - } + string IBeatmapMetadataInfo.Author => AuthorString; } } diff --git a/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs new file mode 100644 index 0000000000..ee946eeeec --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Localisation; + +namespace osu.Game.Beatmaps +{ + public static class BeatmapMetadataInfoExtensions + { + /// + /// An array of all searchable terms provided in contained metadata. + /// + public static string[] GetSearchableTerms(this IBeatmapMetadataInfo metadataInfo) => new[] + { + metadataInfo.Author, + metadataInfo.Artist, + metadataInfo.ArtistUnicode, + metadataInfo.Title, + metadataInfo.TitleUnicode, + metadataInfo.Source, + metadataInfo.Tags + }.Where(s => !string.IsNullOrEmpty(s)).ToArray(); + + /// + /// A user-presentable display title representing this metadata. + /// + public static string GetDisplayTitle(this IBeatmapMetadataInfo metadataInfo) + { + string author = string.IsNullOrEmpty(metadataInfo.Author) ? string.Empty : $"({metadataInfo.Author})"; + return $"{metadataInfo.Artist} - {metadataInfo.Title} {author}".Trim(); + } + + /// + /// A user-presentable display title representing this beatmap, with localisation handling for potentially romanisable fields. + /// + public static RomanisableString GetDisplayTitleRomanisable(this IBeatmapMetadataInfo metadataInfo) + { + string author = string.IsNullOrEmpty(metadataInfo.Author) ? string.Empty : $"({metadataInfo.Author})"; + var artistUnicode = string.IsNullOrEmpty(metadataInfo.ArtistUnicode) ? metadataInfo.Artist : metadataInfo.ArtistUnicode; + var titleUnicode = string.IsNullOrEmpty(metadataInfo.TitleUnicode) ? metadataInfo.Title : metadataInfo.TitleUnicode; + + return new RomanisableString($"{artistUnicode} - {titleUnicode} {author}".Trim(), $"{metadataInfo.Artist} - {metadataInfo.Title} {author}".Trim()); + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapModelDownloader.cs b/osu.Game/Beatmaps/BeatmapModelDownloader.cs new file mode 100644 index 0000000000..30dc95a966 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapModelDownloader.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Beatmaps +{ + public class BeatmapModelDownloader : ModelDownloader + { + protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) => + new DownloadBeatmapSetRequest(set, minimiseDownloadSize); + + public BeatmapModelDownloader(IBeatmapModelManager beatmapModelManager, IAPIProvider api, GameHost host = null) + : base(beatmapModelManager, api, host) + { + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapModelManager.cs b/osu.Game/Beatmaps/BeatmapModelManager.cs new file mode 100644 index 0000000000..9c0fc5ef8a --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapModelManager.cs @@ -0,0 +1,475 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Textures; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps.Formats; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; +using Decoder = osu.Game.Beatmaps.Formats.Decoder; + +namespace osu.Game.Beatmaps +{ + /// + /// Handles ef-core storage of beatmaps. + /// + [ExcludeFromDynamicCompile] + public class BeatmapModelManager : ArchiveModelManager, IBeatmapModelManager + { + /// + /// Fired when a single difficulty has been hidden. + /// + public IBindable> BeatmapHidden => beatmapHidden; + + private readonly Bindable> beatmapHidden = new Bindable>(); + + /// + /// Fired when a single difficulty has been restored. + /// + public IBindable> BeatmapRestored => beatmapRestored; + + /// + /// An online lookup queue component which handles populating online beatmap metadata. + /// + public BeatmapOnlineLookupQueue OnlineLookupQueue { private get; set; } + + /// + /// The game working beatmap cache, used to invalidate entries on changes. + /// + public IWorkingBeatmapCache WorkingBeatmapCache { private get; set; } + + private readonly Bindable> beatmapRestored = new Bindable>(); + + public override IEnumerable HandledExtensions => new[] { ".osz" }; + + protected override string[] HashableFileTypes => new[] { ".osu" }; + + protected override string ImportFromStablePath => "."; + + protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage(); + + private readonly BeatmapStore beatmaps; + private readonly RulesetStore rulesets; + + public BeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, GameHost host = null) + : base(storage, contextFactory, new BeatmapStore(contextFactory), host) + { + this.rulesets = rulesets; + + beatmaps = (BeatmapStore)ModelStore; + beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference(b); + beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference(b); + beatmaps.ItemRemoved += b => WorkingBeatmapCache?.Invalidate(b); + beatmaps.ItemUpdated += obj => WorkingBeatmapCache?.Invalidate(obj); + } + + protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz"; + + protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default) + { + if (archive != null) + beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files); + + foreach (BeatmapInfo b in beatmapSet.Beatmaps) + { + // remove metadata from difficulties where it matches the set + if (beatmapSet.Metadata.Equals(b.Metadata)) + b.Metadata = null; + + b.BeatmapSet = beatmapSet; + } + + validateOnlineIds(beatmapSet); + + bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0); + + if (OnlineLookupQueue != null) + await OnlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false); + + // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID. + if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0)) + { + if (beatmapSet.OnlineBeatmapSetID != null) + { + beatmapSet.OnlineBeatmapSetID = null; + LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs"); + } + } + } + + protected override void PreImport(BeatmapSetInfo beatmapSet) + { + if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null)) + throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}."); + + // check if a set already exists with the same online id, delete if it does. + if (beatmapSet.OnlineBeatmapSetID != null) + { + var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID); + + if (existingOnlineId != null) + { + Delete(existingOnlineId); + + // 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."); + } + } + } + + private void validateOnlineIds(BeatmapSetInfo beatmapSet) + { + var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList(); + + // ensure all IDs are unique + if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1)) + { + LogForModel(beatmapSet, "Found non-unique IDs, resetting..."); + resetIds(); + return; + } + + // find any existing beatmaps in the database that have matching online ids + var existingBeatmaps = QueryBeatmaps(b => beatmapIds.Contains(b.OnlineBeatmapID)).ToList(); + + if (existingBeatmaps.Count > 0) + { + // reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set. + // we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted. + var existing = CheckForExisting(beatmapSet); + + if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b))) + { + LogForModel(beatmapSet, "Found existing import with IDs already, resetting..."); + resetIds(); + } + } + + void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null); + } + + /// + /// Delete a beatmap difficulty. + /// + /// The beatmap difficulty to hide. + public void Hide(BeatmapInfo beatmapInfo) => beatmaps.Hide(beatmapInfo); + + /// + /// Restore a beatmap difficulty. + /// + /// The beatmap difficulty to restore. + public void Restore(BeatmapInfo beatmapInfo) => beatmaps.Restore(beatmapInfo); + + /// + /// Saves an file against a given . + /// + /// The to save the content against. The file referenced by will be replaced. + /// The content to write. + /// The beatmap content to write, null if to be omitted. + public virtual void Save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin beatmapSkin = null) + { + var setInfo = beatmapInfo.BeatmapSet; + + using (var stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw); + + stream.Seek(0, SeekOrigin.Begin); + + using (ContextFactory.GetForWrite()) + { + beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == beatmapInfo.ID); + beatmapInfo.BaseDifficulty.CopyFrom(beatmapContent.Difficulty); + + var metadata = beatmapInfo.Metadata ?? setInfo.Metadata; + + // grab the original file (or create a new one if not found). + var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo(); + + // metadata may have changed; update the path with the standard format. + beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu"; + beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); + + // update existing or populate new file's filename. + fileInfo.Filename = beatmapInfo.Path; + + stream.Seek(0, SeekOrigin.Begin); + ReplaceFile(setInfo, fileInfo, stream); + } + } + + WorkingBeatmapCache?.Invalidate(beatmapInfo); + } + + /// + /// Perform a lookup query on available s. + /// + /// The query. + /// 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)) + return false; + + var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i); + var importIds = import.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i); + + // force re-import if we are not in a sane state. + return existing.OnlineBeatmapSetID == import.OnlineBeatmapSetID && existingIds.SequenceEqual(importIds); + } + + /// + /// Returns a list of all usable s. + /// + /// A list of available . + public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => + GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList(); + + /// + /// Returns a list of all usable s. Note that files are not populated. + /// + /// The level of detail to include in the returned objects. + /// Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases. + /// A list of available . + public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) + { + IQueryable queryable; + + switch (includes) + { + case IncludedDetails.Minimal: + queryable = beatmaps.BeatmapSetsOverview; + break; + + case IncludedDetails.AllButRuleset: + queryable = beatmaps.BeatmapSetsWithoutRuleset; + break; + + case IncludedDetails.AllButFiles: + queryable = beatmaps.BeatmapSetsWithoutFiles; + break; + + default: + queryable = beatmaps.ConsumableItems; + break; + } + + // AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY + // clause which causes queries to take 5-10x longer. + // TODO: remove if upgrading to EF core 3.x. + return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected)); + } + + /// + /// Perform a lookup query on available s. + /// + /// The query. + /// The level of detail to include in the returned objects. + /// Results from the provided query. + public IEnumerable QueryBeatmapSets(Expression> query, IncludedDetails includes = IncludedDetails.All) + { + IQueryable queryable; + + switch (includes) + { + case IncludedDetails.Minimal: + queryable = beatmaps.BeatmapSetsOverview; + break; + + case IncludedDetails.AllButRuleset: + queryable = beatmaps.BeatmapSetsWithoutRuleset; + break; + + case IncludedDetails.AllButFiles: + queryable = beatmaps.BeatmapSetsWithoutFiles; + break; + + default: + queryable = beatmaps.ConsumableItems; + break; + } + + return queryable.AsNoTracking().Where(query); + } + + /// + /// Perform a lookup query on available s. + /// + /// The query. + /// The first result for the provided query, or null if no results were found. + public BeatmapInfo QueryBeatmap(Expression> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query); + + /// + /// Perform a lookup query on available s. + /// + /// The query. + /// Results from the provided query. + public IQueryable QueryBeatmaps(Expression> query) => beatmaps.Beatmaps.AsNoTracking().Where(query); + + public override string HumanisedModelName => "beatmap"; + + protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable items) + => base.CheckLocalAvailability(model, items) + || (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID)); + + protected override BeatmapSetInfo CreateModel(ArchiveReader reader) + { + // let's make sure there are actually .osu files to import. + string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(mapName)) + { + Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database); + return null; + } + + Beatmap beatmap; + using (var stream = new LineBufferedReader(reader.GetStream(mapName))) + beatmap = Decoder.GetDecoder(stream).Decode(stream); + + return new BeatmapSetInfo + { + OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID, + Beatmaps = new List(), + Metadata = beatmap.Metadata, + DateAdded = DateTimeOffset.UtcNow + }; + } + + /// + /// Create all required s for the provided archive. + /// + private List createBeatmapDifficulties(List files) + { + var beatmapInfos = new List(); + + foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) + { + using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath)) + using (var ms = new MemoryStream()) // we need a memory stream so we can seek + using (var sr = new LineBufferedReader(ms)) + { + raw.CopyTo(ms); + ms.Position = 0; + + var decoder = Decoder.GetDecoder(sr); + IBeatmap beatmap = decoder.Decode(sr); + + string hash = ms.ComputeSHA2Hash(); + + if (beatmapInfos.Any(b => b.Hash == hash)) + continue; + + beatmap.BeatmapInfo.Path = file.Filename; + beatmap.BeatmapInfo.Hash = hash; + beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash(); + + var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID); + beatmap.BeatmapInfo.Ruleset = ruleset; + + // TODO: this should be done in a better place once we actually need to dynamically update it. + beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0; + beatmap.BeatmapInfo.Length = calculateLength(beatmap); + beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength(); + + beatmapInfos.Add(beatmap.BeatmapInfo); + } + } + + return beatmapInfos; + } + + private double calculateLength(IBeatmap b) + { + if (!b.HitObjects.Any()) + return 0; + + var lastObject = b.HitObjects.Last(); + + //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). + double endTime = lastObject.GetEndTime(); + double startTime = b.HitObjects.First().StartTime; + + return endTime - startTime; + } + + /// + /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation. + /// + private class DummyConversionBeatmap : WorkingBeatmap + { + private readonly IBeatmap beatmap; + + public DummyConversionBeatmap(IBeatmap beatmap) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + } + + protected override IBeatmap GetBeatmap() => beatmap; + protected override Texture GetBackground() => null; + protected override Track GetBeatmapTrack() => null; + protected internal override ISkin GetSkin() => null; + public override Stream GetStream(string storagePath) => null; + } + } + + /// + /// The level of detail to include in database results. + /// + public enum IncludedDetails + { + /// + /// Only include beatmap difficulties and set level metadata. + /// + Minimal, + + /// + /// Include all difficulties, rulesets, difficulty metadata but no files. + /// + AllButFiles, + + /// + /// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap. + /// + AllButRuleset, + + /// + /// Include everything. + /// + All + } +} diff --git a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs new file mode 100644 index 0000000000..1fe120557d --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs @@ -0,0 +1,222 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using osu.Framework.Development; +using osu.Framework.IO.Network; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Framework.Threading; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using SharpCompress.Compressors; +using SharpCompress.Compressors.BZip2; + +namespace osu.Game.Beatmaps +{ + /// + /// A component which handles population of online IDs for beatmaps using a two part lookup procedure. + /// + /// + /// On creating the component, a copy of a database containing metadata for a large subset of beatmaps (stored to ) will be downloaded if not already present locally. + /// This will always be checked before doing a second online query to get required metadata. + /// + [ExcludeFromDynamicCompile] + public class BeatmapOnlineLookupQueue : IDisposable + { + private readonly IAPIProvider api; + private readonly Storage storage; + + private const int update_queue_request_concurrency = 4; + + private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue)); + + private FileWebRequest cacheDownloadRequest; + + private const string cache_database_name = "online.db"; + + public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage) + { + this.api = api; + this.storage = storage; + + // avoid downloading / using cache for unit tests. + if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name)) + prepareLocalCache(); + } + + public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) + { + return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); + } + + // todo: expose this when we need to do individual difficulty lookups. + protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmapInfo, CancellationToken cancellationToken) + => Task.Factory.StartNew(() => lookup(beatmapSet, beatmapInfo), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + + private void lookup(BeatmapSetInfo set, BeatmapInfo beatmapInfo) + { + if (checkLocalCache(set, beatmapInfo)) + return; + + if (api?.State.Value != APIState.Online) + return; + + var req = new GetBeatmapRequest(beatmapInfo); + + req.Failure += fail; + + try + { + // intentionally blocking to limit web request concurrency + api.Perform(req); + + var res = req.Response; + + if (res != null) + { + beatmapInfo.Status = res.Status; + beatmapInfo.BeatmapSet.Status = res.BeatmapSet.Status; + beatmapInfo.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; + beatmapInfo.OnlineBeatmapID = res.OnlineBeatmapID; + + if (beatmapInfo.Metadata != null) + beatmapInfo.Metadata.AuthorID = res.AuthorID; + + if (beatmapInfo.BeatmapSet.Metadata != null) + beatmapInfo.BeatmapSet.Metadata.AuthorID = res.AuthorID; + + logForModel(set, $"Online retrieval mapped {beatmapInfo} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); + } + } + catch (Exception e) + { + fail(e); + } + + void fail(Exception e) + { + beatmapInfo.OnlineBeatmapID = null; + logForModel(set, $"Online retrieval failed for {beatmapInfo} ({e.Message})"); + } + } + + private void prepareLocalCache() + { + string cacheFilePath = storage.GetFullPath(cache_database_name); + string compressedCacheFilePath = $"{cacheFilePath}.bz2"; + + cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}"); + + cacheDownloadRequest.Failed += ex => + { + File.Delete(compressedCacheFilePath); + File.Delete(cacheFilePath); + + Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database); + }; + + cacheDownloadRequest.Finished += () => + { + try + { + using (var stream = File.OpenRead(cacheDownloadRequest.Filename)) + using (var outStream = File.OpenWrite(cacheFilePath)) + using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false)) + bz2.CopyTo(outStream); + + // set to null on completion to allow lookups to begin using the new source + cacheDownloadRequest = null; + } + catch (Exception ex) + { + Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database); + File.Delete(cacheFilePath); + } + finally + { + File.Delete(compressedCacheFilePath); + } + }; + + cacheDownloadRequest.PerformAsync(); + } + + private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmapInfo) + { + // download is in progress (or was, and failed). + if (cacheDownloadRequest != null) + return false; + + // database is unavailable. + if (!storage.Exists(cache_database_name)) + return false; + + if (string.IsNullOrEmpty(beatmapInfo.MD5Hash) + && string.IsNullOrEmpty(beatmapInfo.Path) + && beatmapInfo.OnlineBeatmapID == null) + return false; + + try + { + using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage))) + { + db.Open(); + + using (var cmd = db.CreateCommand()) + { + cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path"; + + cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmapInfo.MD5Hash)); + cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmapInfo.OnlineBeatmapID ?? (object)DBNull.Value)); + cmd.Parameters.Add(new SqliteParameter("@Path", beatmapInfo.Path)); + + using (var reader = cmd.ExecuteReader()) + { + if (reader.Read()) + { + var status = (BeatmapSetOnlineStatus)reader.GetByte(2); + + beatmapInfo.Status = status; + beatmapInfo.BeatmapSet.Status = status; + beatmapInfo.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0); + beatmapInfo.OnlineBeatmapID = reader.GetInt32(1); + + if (beatmapInfo.Metadata != null) + beatmapInfo.Metadata.AuthorID = reader.GetInt32(3); + + if (beatmapInfo.BeatmapSet.Metadata != null) + beatmapInfo.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3); + + logForModel(set, $"Cached local retrieval for {beatmapInfo}."); + return true; + } + } + } + } + } + catch (Exception ex) + { + logForModel(set, $"Cached local retrieval for {beatmapInfo} failed with {ex}."); + } + + return false; + } + + private void logForModel(BeatmapSetInfo set, string message) => + ArchiveModelManager.LogForModel(set, $"[{nameof(BeatmapOnlineLookupQueue)}] {message}"); + + public void Dispose() + { + cacheDownloadRequest?.Dispose(); + updateScheduler?.Dispose(); + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapProcessor.cs b/osu.Game/Beatmaps/BeatmapProcessor.cs index b7b5adc52e..cdeaab06ed 100644 --- a/osu.Game/Beatmaps/BeatmapProcessor.cs +++ b/osu.Game/Beatmaps/BeatmapProcessor.cs @@ -34,19 +34,19 @@ namespace osu.Game.Beatmaps isFirst = false; } + obj.ComboIndex = lastObj?.ComboIndex ?? 0; + obj.ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + obj.IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + if (obj.NewCombo) { obj.IndexInCurrentCombo = 0; - obj.ComboIndex = (lastObj?.ComboIndex ?? 0) + obj.ComboOffset + 1; + obj.ComboIndex++; + obj.ComboIndexWithOffsets += obj.ComboOffset + 1; if (lastObj != null) lastObj.LastInCombo = true; } - else if (lastObj != null) - { - obj.IndexInCurrentCombo = lastObj.IndexInCurrentCombo + 1; - obj.ComboIndex = lastObj.ComboIndex; - } lastObj = obj; } diff --git a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs index 3a55dc1577..ce50463f05 100644 --- a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs @@ -7,7 +7,7 @@ using osu.Game.IO; namespace osu.Game.Beatmaps { - public class BeatmapSetFileInfo : INamedFileInfo, IHasPrimaryKey + public class BeatmapSetFileInfo : INamedFileInfo, IHasPrimaryKey, INamedFileUsage { public int ID { get; set; } @@ -19,5 +19,7 @@ namespace osu.Game.Beatmaps [Required] public string Filename { get; set; } + + public IFileInfo File => FileInfo; } } diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 3b1ff4ced0..8b01831b3c 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -12,7 +12,7 @@ using osu.Game.Database; namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] - public class BeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable + public class BeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable, IBeatmapSetInfo { public int ID { get; set; } @@ -61,8 +61,6 @@ namespace osu.Game.Beatmaps public string Hash { get; set; } - public string StoryboardFile => Files.Find(f => f.Filename.EndsWith(".osb", StringComparison.OrdinalIgnoreCase))?.Filename; - /// /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. /// The path returned is relative to the user file storage. @@ -90,5 +88,19 @@ namespace osu.Game.Beatmaps return ReferenceEquals(this, other); } + + #region Implementation of IHasOnlineID + + public int? OnlineID => OnlineBeatmapSetID; + + #endregion + + #region Implementation of IBeatmapSetInfo + + IBeatmapMetadataInfo IBeatmapSetInfo.Metadata => Metadata; + IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; + IEnumerable IBeatmapSetInfo.Files => Files; + + #endregion } } diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs b/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs index 48f1f0ce68..3658dbab83 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs @@ -90,6 +90,12 @@ namespace osu.Game.Beatmaps /// The song language of this beatmap set. /// public BeatmapSetOnlineLanguage Language { get; set; } + + /// + /// The track ID of this beatmap set. + /// Non-null only if the track is linked to a featured artist track entry. + /// + public int? TrackId { get; set; } } public class BeatmapSetOnlineGenre diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs index ae5a44cfcd..edaf044466 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs @@ -1,17 +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.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Beatmaps { public enum BeatmapSetOnlineStatus { None = -3, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusGraveyard))] Graveyard = -2, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusWip))] WIP = -1, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusPending))] Pending = 0, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusRanked))] Ranked = 1, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusApproved))] Approved = 2, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusQualified))] Qualified = 3, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusLoved))] Loved = 4, } diff --git a/osu.Game/Beatmaps/BeatmapStore.cs b/osu.Game/Beatmaps/BeatmapStore.cs index 642bafd2ac..197581db88 100644 --- a/osu.Game/Beatmaps/BeatmapStore.cs +++ b/osu.Game/Beatmaps/BeatmapStore.cs @@ -25,40 +25,40 @@ namespace osu.Game.Beatmaps /// /// Hide a in the database. /// - /// The beatmap to hide. + /// The beatmap to hide. /// Whether the beatmap's was changed. - public bool Hide(BeatmapInfo beatmap) + public bool Hide(BeatmapInfo beatmapInfo) { using (ContextFactory.GetForWrite()) { - Refresh(ref beatmap, Beatmaps); + Refresh(ref beatmapInfo, Beatmaps); - if (beatmap.Hidden) return false; + if (beatmapInfo.Hidden) return false; - beatmap.Hidden = true; + beatmapInfo.Hidden = true; } - BeatmapHidden?.Invoke(beatmap); + BeatmapHidden?.Invoke(beatmapInfo); return true; } /// /// Restore a previously hidden . /// - /// The beatmap to restore. + /// The beatmap to restore. /// Whether the beatmap's was changed. - public bool Restore(BeatmapInfo beatmap) + public bool Restore(BeatmapInfo beatmapInfo) { using (ContextFactory.GetForWrite()) { - Refresh(ref beatmap, Beatmaps); + Refresh(ref beatmapInfo, Beatmaps); - if (!beatmap.Hidden) return false; + if (!beatmapInfo.Hidden) return false; - beatmap.Hidden = false; + beatmapInfo.Hidden = false; } - BeatmapRestored?.Invoke(beatmap); + BeatmapRestored?.Invoke(beatmapInfo); return true; } @@ -92,6 +92,13 @@ namespace osu.Game.Beatmaps .Include(s => s.Beatmaps) .AsNoTracking(); + public IQueryable BeatmapSetsWithoutRuleset => ContextFactory.Get().BeatmapSetInfo + .Include(s => s.Metadata) + .Include(s => s.Files).ThenInclude(f => f.FileInfo) + .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) + .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) + .AsNoTracking(); + public IQueryable BeatmapSetsWithoutFiles => ContextFactory.Get().BeatmapSetInfo .Include(s => s.Metadata) .Include(s => s.Beatmaps).ThenInclude(s => s.Ruleset) diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index e8dc623ddb..8203f2e968 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -2,16 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System; +using Newtonsoft.Json; 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. /// + [JsonIgnore] public double Time => controlPointGroup?.Time ?? 0; private ControlPointGroup controlPointGroup; @@ -32,7 +35,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 25d0843a71..3ff40fe194 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -10,11 +10,12 @@ 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. @@ -40,14 +41,6 @@ namespace osu.Game.Beatmaps.ControlPoints private readonly SortedList difficultyPoints = new SortedList(Comparer.Default); - /// - /// All sound points. - /// - [JsonProperty] - public IBindableList SamplePoints => samplePoints; - - private readonly BindableList samplePoints = new BindableList(); - /// /// All effect points. /// @@ -68,7 +61,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); + public DifficultyControlPoint DifficultyPointAt(double time) => BinarySearchWithFallback(DifficultyPoints, time, DifficultyControlPoint.DEFAULT); /// /// Finds the effect control point that is active at . @@ -76,15 +69,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); - - /// - /// Finds the sound control point that is active at . - /// - /// 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); + public EffectControlPoint EffectPointAt(double time) => BinarySearchWithFallback(EffectPoints, time, EffectControlPoint.DEFAULT); /// /// Finds the timing control point that is active at . @@ -92,7 +77,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); + public TimingControlPoint TimingPointAt(double time) => BinarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : TimingControlPoint.DEFAULT); /// /// Finds the maximum BPM represented by any timing control point. @@ -111,12 +96,11 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// Remove all s and return to a pristine state. /// - public void Clear() + public virtual void Clear() { groups.Clear(); timingPoints.Clear(); difficultyPoints.Clear(); - samplePoints.Clear(); effectPoints.Clear(); } @@ -128,7 +112,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// Whether the control point was added. public bool Add(double time, ControlPoint controlPoint) { - if (checkAlreadyExisting(time, controlPoint)) + if (CheckAlreadyExisting(time, controlPoint)) return false; GroupAt(time, true).Add(controlPoint); @@ -146,8 +130,8 @@ namespace osu.Game.Beatmaps.ControlPoints if (addIfNotExisting) { - newGroup.ItemAdded += groupItemAdded; - newGroup.ItemRemoved += groupItemRemoved; + newGroup.ItemAdded += GroupItemAdded; + newGroup.ItemRemoved += GroupItemRemoved; groups.Insert(~i, newGroup); return newGroup; @@ -161,8 +145,8 @@ namespace osu.Game.Beatmaps.ControlPoints foreach (var item in group.ControlPoints.ToArray()) group.Remove(item); - group.ItemAdded -= groupItemAdded; - group.ItemRemoved -= groupItemRemoved; + group.ItemAdded -= GroupItemAdded; + group.ItemRemoved -= GroupItemRemoved; groups.Remove(group); } @@ -227,10 +211,10 @@ namespace osu.Game.Beatmaps.ControlPoints /// The time to find the control point at. /// The control point to use when is before any control points. /// The active control point at , or a fallback if none found. - private T binarySearchWithFallback(IReadOnlyList list, double time, T fallback) + protected T BinarySearchWithFallback(IReadOnlyList list, double time, T fallback) where T : ControlPoint { - return binarySearch(list, time) ?? fallback; + return BinarySearch(list, time) ?? fallback; } /// @@ -239,7 +223,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// The list to search. /// The time to find the control point at. /// The active control point at . - private T binarySearch(IReadOnlyList list, double time) + protected virtual T BinarySearch(IReadOnlyList list, double time) where T : ControlPoint { if (list == null) @@ -279,7 +263,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// The time to find the timing control point at. /// A point to be added. /// Whether the new point should be added. - private bool checkAlreadyExisting(double time, ControlPoint newPoint) + protected virtual bool CheckAlreadyExisting(double time, ControlPoint newPoint) { ControlPoint existing = null; @@ -287,17 +271,13 @@ namespace osu.Game.Beatmaps.ControlPoints { case TimingControlPoint _: // Timing points are a special case and need to be added regardless of fallback availability. - existing = binarySearch(TimingPoints, time); + existing = BinarySearch(TimingPoints, time); break; case EffectControlPoint _: existing = EffectPointAt(time); break; - case SampleControlPoint _: - existing = binarySearch(SamplePoints, time); - break; - case DifficultyControlPoint _: existing = DifficultyPointAt(time); break; @@ -306,7 +286,7 @@ namespace osu.Game.Beatmaps.ControlPoints return newPoint?.IsRedundant(existing) == true; } - private void groupItemAdded(ControlPoint controlPoint) + protected virtual void GroupItemAdded(ControlPoint controlPoint) { switch (controlPoint) { @@ -318,17 +298,13 @@ namespace osu.Game.Beatmaps.ControlPoints effectPoints.Add(typed); break; - case SampleControlPoint typed: - samplePoints.Add(typed); - break; - case DifficultyControlPoint typed: difficultyPoints.Add(typed); break; } } - private void groupItemRemoved(ControlPoint controlPoint) + protected virtual void GroupItemRemoved(ControlPoint controlPoint) { switch (controlPoint) { @@ -340,22 +316,18 @@ namespace osu.Game.Beatmaps.ControlPoints effectPoints.Remove(typed); break; - case SampleControlPoint typed: - samplePoints.Remove(typed); - break; - case DifficultyControlPoint typed: difficultyPoints.Remove(typed); break; } } - public ControlPointInfo CreateCopy() + public ControlPointInfo DeepClone() { - var controlPointInfo = new ControlPointInfo(); + var controlPointInfo = (ControlPointInfo)Activator.CreateInstance(GetType()); 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/CountdownType.cs b/osu.Game/Beatmaps/CountdownType.cs new file mode 100644 index 0000000000..73f85bf701 --- /dev/null +++ b/osu.Game/Beatmaps/CountdownType.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.ComponentModel; + +namespace osu.Game.Beatmaps +{ + /// + /// The type of countdown shown before the start of gameplay on a given beatmap. + /// + public enum CountdownType + { + None = 0, + + [Description("Normal")] + Normal = 1, + + [Description("Half speed")] + HalfSpeed = 2, + + [Description("Double speed")] + DoubleSpeed = 3 + } +} diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index ca910e70b8..b1b1e58ab7 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -62,14 +62,14 @@ namespace osu.Game.Beatmaps if (!recommendedDifficultyMapping.TryGetValue(r, out var recommendation)) continue; - BeatmapInfo beatmap = beatmaps.Where(b => b.Ruleset.Equals(r)).OrderBy(b => + BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.Equals(r)).OrderBy(b => { var difference = b.StarDifficulty - recommendation; return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder }).FirstOrDefault(); - if (beatmap != null) - return beatmap; + if (beatmapInfo != null) + return beatmapInfo; } return null; diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index f6e03d40ff..ffc010b3a3 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -28,7 +30,7 @@ namespace osu.Game.Beatmaps.Drawables status = value; Alpha = value == BeatmapSetOnlineStatus.None ? 0 : 1; - statusText.Text = value.ToString().ToUpperInvariant(); + statusText.Text = value.GetLocalisableDescription().ToUpper(); } } diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index c62b803d1a..880d70aec2 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -16,7 +16,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osuTK; @@ -24,7 +23,7 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class DifficultyIcon : CompositeDrawable, IHasCustomTooltip + public class DifficultyIcon : CompositeDrawable, IHasCustomTooltip { private readonly Container iconContainer; @@ -38,7 +37,7 @@ namespace osu.Game.Beatmaps.Drawables } [NotNull] - private readonly BeatmapInfo beatmap; + private readonly BeatmapInfo beatmapInfo; [CanBeNull] private readonly RulesetInfo ruleset; @@ -57,26 +56,26 @@ namespace osu.Game.Beatmaps.Drawables /// /// Creates a new with a given and combination. /// - /// The beatmap to show the difficulty of. + /// The beatmap to show the difficulty of. /// The ruleset to show the difficulty with. /// The mods to show the difficulty with. /// Whether to display a tooltip when hovered. - public DifficultyIcon([NotNull] BeatmapInfo beatmap, [CanBeNull] RulesetInfo ruleset, [CanBeNull] IReadOnlyList mods, bool shouldShowTooltip = true) - : this(beatmap, shouldShowTooltip) + public DifficultyIcon([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo ruleset, [CanBeNull] IReadOnlyList mods, bool shouldShowTooltip = true) + : this(beatmapInfo, shouldShowTooltip) { - this.ruleset = ruleset ?? beatmap.Ruleset; + this.ruleset = ruleset ?? beatmapInfo.Ruleset; this.mods = mods ?? Array.Empty(); } /// /// Creates a new that follows the currently-selected ruleset and mods. /// - /// The beatmap to show the difficulty of. + /// The beatmap to show the difficulty of. /// Whether to display a tooltip when hovered. /// Whether to perform difficulty lookup (including calculation if necessary). - public DifficultyIcon([NotNull] BeatmapInfo beatmap, bool shouldShowTooltip = true, bool performBackgroundDifficultyLookup = true) + public DifficultyIcon([NotNull] BeatmapInfo beatmapInfo, bool shouldShowTooltip = true, bool performBackgroundDifficultyLookup = true) { - this.beatmap = beatmap ?? throw new ArgumentNullException(nameof(beatmap)); + this.beatmapInfo = beatmapInfo ?? throw new ArgumentNullException(nameof(beatmapInfo)); this.shouldShowTooltip = shouldShowTooltip; this.performBackgroundDifficultyLookup = performBackgroundDifficultyLookup; @@ -93,20 +92,20 @@ namespace osu.Game.Beatmaps.Drawables new CircularContainer { RelativeSizeAxes = Axes.Both, - Scale = new Vector2(0.84f), Anchor = Anchor.Centre, Origin = Anchor.Centre, Masking = true, EdgeEffect = new EdgeEffectParameters { - Colour = Color4.Black.Opacity(0.08f), + Colour = Color4.Black.Opacity(0.06f), + Type = EdgeEffectType.Shadow, - Radius = 5, + Radius = 3, }, Child = background = new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.ForDifficultyRating(beatmap.DifficultyRating) // Default value that will be re-populated once difficulty calculation completes + Colour = colours.ForStarDifficulty(beatmapInfo.StarDifficulty) // Default value that will be re-populated once difficulty calculation completes }, }, new ConstrainedIconContainer @@ -115,27 +114,27 @@ namespace osu.Game.Beatmaps.Drawables Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, // the null coalesce here is only present to make unit tests work (ruleset dlls aren't copied correctly for testing at the moment) - Icon = (ruleset ?? beatmap.Ruleset)?.CreateInstance()?.CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle } + Icon = (ruleset ?? beatmapInfo.Ruleset)?.CreateInstance()?.CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle } }, }; if (performBackgroundDifficultyLookup) - iconContainer.Add(new DelayedLoadUnloadWrapper(() => new DifficultyRetriever(beatmap, ruleset, mods) { StarDifficulty = { BindTarget = difficultyBindable } }, 0)); + iconContainer.Add(new DelayedLoadUnloadWrapper(() => new DifficultyRetriever(beatmapInfo, ruleset, mods) { StarDifficulty = { BindTarget = difficultyBindable } }, 0)); else - difficultyBindable.Value = new StarDifficulty(beatmap.StarDifficulty, 0); + difficultyBindable.Value = new StarDifficulty(beatmapInfo.StarDifficulty, 0); - difficultyBindable.BindValueChanged(difficulty => background.Colour = colours.ForDifficultyRating(difficulty.NewValue.DifficultyRating)); + difficultyBindable.BindValueChanged(difficulty => background.Colour = colours.ForStarDifficulty(difficulty.NewValue.Stars)); } - public ITooltip GetCustomTooltip() => new DifficultyIconTooltip(); + ITooltip IHasCustomTooltip.GetCustomTooltip() => new DifficultyIconTooltip(); - public object TooltipContent => shouldShowTooltip ? new DifficultyIconTooltipContent(beatmap, difficultyBindable) : null; + DifficultyIconTooltipContent IHasCustomTooltip.TooltipContent => shouldShowTooltip ? new DifficultyIconTooltipContent(beatmapInfo, difficultyBindable) : null; private class DifficultyRetriever : Component { public readonly Bindable StarDifficulty = new Bindable(); - private readonly BeatmapInfo beatmap; + private readonly BeatmapInfo beatmapInfo; private readonly RulesetInfo ruleset; private readonly IReadOnlyList mods; @@ -144,9 +143,9 @@ namespace osu.Game.Beatmaps.Drawables [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } - public DifficultyRetriever(BeatmapInfo beatmap, RulesetInfo ruleset, IReadOnlyList mods) + public DifficultyRetriever(BeatmapInfo beatmapInfo, RulesetInfo ruleset, IReadOnlyList mods) { - this.beatmap = beatmap; + this.beatmapInfo = beatmapInfo; this.ruleset = ruleset; this.mods = mods; } @@ -158,8 +157,8 @@ namespace osu.Game.Beatmaps.Drawables { difficultyCancellation = new CancellationTokenSource(); localStarDifficulty = ruleset != null - ? difficultyCache.GetBindableDifficulty(beatmap, ruleset, mods, difficultyCancellation.Token) - : difficultyCache.GetBindableDifficulty(beatmap, difficultyCancellation.Token); + ? difficultyCache.GetBindableDifficulty(beatmapInfo, ruleset, mods, difficultyCancellation.Token) + : difficultyCache.GetBindableDifficulty(beatmapInfo, difficultyCancellation.Token); localStarDifficulty.BindValueChanged(d => { if (d.NewValue is StarDifficulty diff) @@ -173,115 +172,5 @@ namespace osu.Game.Beatmaps.Drawables difficultyCancellation?.Cancel(); } } - - private class DifficultyIconTooltipContent - { - public readonly BeatmapInfo Beatmap; - public readonly IBindable Difficulty; - - public DifficultyIconTooltipContent(BeatmapInfo beatmap, IBindable difficulty) - { - Beatmap = beatmap; - Difficulty = difficulty; - } - } - - private class DifficultyIconTooltip : VisibilityContainer, ITooltip - { - private readonly OsuSpriteText difficultyName, starRating; - private readonly Box background; - private readonly FillFlowContainer difficultyFlow; - - public DifficultyIconTooltip() - { - AutoSizeAxes = Axes.Both; - Masking = true; - CornerRadius = 5; - - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - AutoSizeDuration = 200, - AutoSizeEasing = Easing.OutQuint, - Direction = FillDirection.Vertical, - Padding = new MarginPadding(10), - Children = new Drawable[] - { - difficultyName = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), - }, - difficultyFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - starRating = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), - }, - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Margin = new MarginPadding { Left = 4 }, - Icon = FontAwesome.Solid.Star, - Size = new Vector2(12), - }, - } - } - } - } - }; - } - - [Resolved] - private OsuColour colours { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - background.Colour = colours.Gray3; - } - - private readonly IBindable starDifficulty = new Bindable(); - - public bool SetContent(object content) - { - if (!(content is DifficultyIconTooltipContent iconContent)) - return false; - - difficultyName.Text = iconContent.Beatmap.Version; - - starDifficulty.UnbindAll(); - starDifficulty.BindTo(iconContent.Difficulty); - starDifficulty.BindValueChanged(difficulty => - { - starRating.Text = $"{difficulty.NewValue.Stars:0.##}"; - difficultyFlow.Colour = colours.ForDifficultyRating(difficulty.NewValue.DifficultyRating, true); - }, true); - - return true; - } - - public void Move(Vector2 pos) => Position = pos; - - protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); - - protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); - } } } diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs new file mode 100644 index 0000000000..d4c9f83a0a --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs @@ -0,0 +1,121 @@ +// Copyright (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.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Beatmaps.Drawables +{ + internal class DifficultyIconTooltip : VisibilityContainer, ITooltip + { + private readonly OsuSpriteText difficultyName, starRating; + private readonly Box background; + private readonly FillFlowContainer difficultyFlow; + + public DifficultyIconTooltip() + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 200, + AutoSizeEasing = Easing.OutQuint, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + difficultyName = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), + }, + difficultyFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + starRating = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Left = 4 }, + Icon = FontAwesome.Solid.Star, + Size = new Vector2(12), + }, + } + } + } + } + }; + } + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + background.Colour = colours.Gray3; + } + + private readonly IBindable starDifficulty = new Bindable(); + + public void SetContent(DifficultyIconTooltipContent content) + { + difficultyName.Text = content.BeatmapInfo.Version; + + starDifficulty.UnbindAll(); + starDifficulty.BindTo(content.Difficulty); + starDifficulty.BindValueChanged(difficulty => + { + starRating.Text = $"{difficulty.NewValue.Stars:0.##}"; + difficultyFlow.Colour = colours.ForStarDifficulty(difficulty.NewValue.Stars); + }, true); + } + + public void Move(Vector2 pos) => Position = pos; + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + } + + internal class DifficultyIconTooltipContent + { + public readonly BeatmapInfo BeatmapInfo; + public readonly IBindable Difficulty; + + public DifficultyIconTooltipContent(BeatmapInfo beatmapInfo, IBindable difficulty) + { + BeatmapInfo = beatmapInfo; + Difficulty = difficulty; + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs new file mode 100644 index 0000000000..dde7680989 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -0,0 +1,168 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Beatmaps.Drawables +{ + /// + /// A pill that displays the star rating of a beatmap. + /// + public class StarRatingDisplay : CompositeDrawable, IHasCurrentValue + { + private readonly bool animated; + private readonly Box background; + private readonly SpriteIcon starIcon; + private readonly OsuSpriteText starsText; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly Bindable displayedStars = new BindableDouble(); + + /// + /// The currently displayed stars of this display wrapped in a bindable. + /// This bindable gets transformed on change rather than instantaneous, if animation is enabled. + /// + public IBindable DisplayedStars => displayedStars; + + [Resolved] + private OsuColour colours { get; set; } + + [Resolved(canBeNull: true)] + private OverlayColourProvider colourProvider { get; set; } + + /// + /// Creates a new using an already computed . + /// + /// The already computed to display. + /// The size of the star rating display. + /// Whether the star rating display will perform transforms on change rather than updating instantaneously. + public StarRatingDisplay(StarDifficulty starDifficulty, StarRatingDisplaySize size = StarRatingDisplaySize.Regular, bool animated = false) + { + this.animated = animated; + + Current.Value = starDifficulty; + + AutoSizeAxes = Axes.Both; + + MarginPadding margin = default; + + switch (size) + { + case StarRatingDisplaySize.Small: + margin = new MarginPadding { Horizontal = 7f }; + break; + + case StarRatingDisplaySize.Range: + margin = new MarginPadding { Horizontal = 8f }; + break; + + case StarRatingDisplaySize.Regular: + margin = new MarginPadding { Horizontal = 8f, Vertical = 2f }; + break; + } + + InternalChild = new CircularContainer + { + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Margin = margin, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 3f), + new Dimension(GridSizeMode.AutoSize, minSize: 25f), + }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] + { + new[] + { + starIcon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Star, + Size = new Vector2(8f), + }, + Empty(), + starsText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Bottom = 1.5f }, + // todo: this should be size: 12f, but to match up with the design, it needs to be 14.4f + // see https://github.com/ppy/osu-framework/issues/3271. + Font = OsuFont.Torus.With(size: 14.4f, weight: FontWeight.Bold), + Shadow = false, + }, + } + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(c => + { + if (animated) + this.TransformBindableTo(displayedStars, c.NewValue.Stars, 750, Easing.OutQuint); + else + displayedStars.Value = c.NewValue.Stars; + }); + + displayedStars.Value = Current.Value.Stars; + + displayedStars.BindValueChanged(s => + { + starsText.Text = s.NewValue.ToLocalisableString("0.00"); + + background.Colour = colours.ForStarDifficulty(s.NewValue); + + starIcon.Colour = s.NewValue >= 6.5 ? colours.Orange1 : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); + starsText.Colour = s.NewValue >= 6.5 ? colours.Orange1 : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); + }, true); + } + } + + public enum StarRatingDisplaySize + { + Small, + Range, + Regular, + } +} diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index ea7f45e53f..acfd01a3c8 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -50,7 +50,7 @@ namespace osu.Game.Beatmaps protected override Track GetBeatmapTrack() => GetVirtualTrack(); - protected override ISkin GetSkin() => null; + protected internal override ISkin GetSkin() => null; public override Stream GetStream(string storagePath) => null; diff --git a/osu.Game/Beatmaps/Formats/IHasComboColours.cs b/osu.Game/Beatmaps/Formats/IHasComboColours.cs index 41c85db063..853a590595 100644 --- a/osu.Game/Beatmaps/Formats/IHasComboColours.cs +++ b/osu.Game/Beatmaps/Formats/IHasComboColours.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 osuTK.Graphics; @@ -13,9 +14,17 @@ namespace osu.Game.Beatmaps.Formats /// IReadOnlyList ComboColours { get; } + /// + /// The list of custom combo colours. + /// If non-empty, will return these colours; + /// if empty, will fall back to default combo colours. + /// + List CustomComboColours { get; } + /// /// Adds combo colours to the list. /// + [Obsolete("Use CustomComboColours directly.")] // can be removed 20220215 void AddComboColours(params Color4[] colours); } } diff --git a/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs index 988968fa42..0d5c48f64d 100644 --- a/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs @@ -18,7 +18,7 @@ namespace osu.Game.Beatmaps.Formats stream.ReadToEnd().DeserializeInto(output); foreach (var hitObject in output.HitObjects) - hitObject.ApplyDefaults(output.ControlPointInfo, output.BeatmapInfo.BaseDifficulty); + hitObject.ApplyDefaults(output.ControlPointInfo, output.Difficulty); } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 40bc75e847..f71b148008 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -44,6 +44,13 @@ namespace osu.Game.Beatmaps.Formats offset = FormatVersion < 5 ? 24 : 0; } + protected override Beatmap CreateTemplateObject() + { + var templateBeatmap = base.CreateTemplateObject(); + templateBeatmap.ControlPointInfo = new LegacyControlPointInfo(); + return templateBeatmap; + } + protected override void ParseStreamInto(LineBufferedReader stream, Beatmap beatmap) { this.beatmap = beatmap; @@ -60,7 +67,7 @@ namespace osu.Game.Beatmaps.Formats this.beatmap.HitObjects = this.beatmap.HitObjects.OrderBy(h => h.StartTime).ToList(); foreach (var hitObject in this.beatmap.HitObjects) - hitObject.ApplyDefaults(this.beatmap.ControlPointInfo, this.beatmap.BeatmapInfo.BaseDifficulty); + hitObject.ApplyDefaults(this.beatmap.ControlPointInfo, this.beatmap.Difficulty); } protected override bool ShouldSkipLine(string line) => base.ShouldSkipLine(line) || line.StartsWith(' ') || line.StartsWith('_'); @@ -121,10 +128,6 @@ namespace osu.Game.Beatmaps.Formats metadata.PreviewTime = getOffsetTime(Parsing.ParseInt(pair.Value)); break; - case @"Countdown": - beatmap.BeatmapInfo.Countdown = Parsing.ParseInt(pair.Value) == 1; - break; - case @"SampleSet": defaultSampleBank = (LegacySampleBank)Enum.Parse(typeof(LegacySampleBank), pair.Value); break; @@ -176,6 +179,18 @@ namespace osu.Game.Beatmaps.Formats case @"EpilepsyWarning": beatmap.BeatmapInfo.EpilepsyWarning = Parsing.ParseInt(pair.Value) == 1; break; + + case @"SamplesMatchPlaybackRate": + beatmap.BeatmapInfo.SamplesMatchPlaybackRate = Parsing.ParseInt(pair.Value) == 1; + break; + + case @"Countdown": + beatmap.BeatmapInfo.Countdown = (CountdownType)Enum.Parse(typeof(CountdownType), pair.Value); + break; + + case @"CountdownOffset": + beatmap.BeatmapInfo.CountdownOffset = Parsing.ParseInt(pair.Value); + break; } } @@ -261,7 +276,7 @@ namespace osu.Game.Beatmaps.Formats { var pair = SplitKeyVal(line); - var difficulty = beatmap.BeatmapInfo.BaseDifficulty; + var difficulty = beatmap.Difficulty; switch (pair.Key) { @@ -426,8 +441,13 @@ namespace osu.Game.Beatmaps.Formats parser ??= new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); var obj = parser.Parse(line); + if (obj != null) + { + obj.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + beatmap.HitObjects.Add(obj); + } } private int getOffsetTime(int time) => time + (ApplyOffsets ? offset : 0); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index acbf57d25f..74b3c178cd 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -24,6 +24,12 @@ namespace osu.Game.Beatmaps.Formats { public const int LATEST_VERSION = 128; + /// + /// osu! is generally slower than taiko, so a factor is added to increase + /// speed. This must be used everywhere slider length or beat length is used. + /// + public const float LEGACY_TAIKO_VELOCITY_MULTIPLIER = 1.4f; + private readonly IBeatmap beatmap; [CanBeNull] @@ -79,9 +85,8 @@ namespace osu.Game.Beatmaps.Formats if (beatmap.Metadata.AudioFile != null) writer.WriteLine(FormattableString.Invariant($"AudioFilename: {Path.GetFileName(beatmap.Metadata.AudioFile)}")); writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.BeatmapInfo.AudioLeadIn}")); writer.WriteLine(FormattableString.Invariant($"PreviewTime: {beatmap.Metadata.PreviewTime}")); - // Todo: Not all countdown types are supported by lazer yet - writer.WriteLine(FormattableString.Invariant($"Countdown: {(beatmap.BeatmapInfo.Countdown ? '1' : '0')}")); - writer.WriteLine(FormattableString.Invariant($"SampleSet: {toLegacySampleBank(beatmap.ControlPointInfo.SamplePointAt(double.MinValue).SampleBank)}")); + writer.WriteLine(FormattableString.Invariant($"Countdown: {(int)beatmap.BeatmapInfo.Countdown}")); + writer.WriteLine(FormattableString.Invariant($"SampleSet: {toLegacySampleBank((beatmap.HitObjects.FirstOrDefault()?.SampleControlPoint ?? SampleControlPoint.DEFAULT).SampleBank)}")); writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.BeatmapInfo.StackLeniency}")); writer.WriteLine(FormattableString.Invariant($"Mode: {beatmap.BeatmapInfo.RulesetID}")); writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.BeatmapInfo.LetterboxInBreaks ? '1' : '0')}")); @@ -93,15 +98,15 @@ namespace osu.Game.Beatmaps.Formats // writer.WriteLine(@"OverlayPosition: " + b.OverlayPosition); // if (!string.IsNullOrEmpty(b.SkinPreference)) // writer.WriteLine(@"SkinPreference:" + b.SkinPreference); - // if (b.EpilepsyWarning) - // writer.WriteLine(@"EpilepsyWarning: 1"); - // if (b.CountdownOffset > 0) - // writer.WriteLine(@"CountdownOffset: " + b.CountdownOffset.ToString()); + if (beatmap.BeatmapInfo.EpilepsyWarning) + writer.WriteLine(@"EpilepsyWarning: 1"); + if (beatmap.BeatmapInfo.CountdownOffset > 0) + writer.WriteLine(FormattableString.Invariant($@"CountdownOffset: {beatmap.BeatmapInfo.CountdownOffset}")); if (beatmap.BeatmapInfo.RulesetID == 3) writer.WriteLine(FormattableString.Invariant($"SpecialStyle: {(beatmap.BeatmapInfo.SpecialStyle ? '1' : '0')}")); writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.BeatmapInfo.WidescreenStoryboard ? '1' : '0')}")); - // if (b.SamplesMatchPlaybackRate) - // writer.WriteLine(@"SamplesMatchPlaybackRate: 1"); + if (beatmap.BeatmapInfo.SamplesMatchPlaybackRate) + writer.WriteLine(@"SamplesMatchPlaybackRate: 1"); } private void handleEditor(TextWriter writer) @@ -136,17 +141,17 @@ namespace osu.Game.Beatmaps.Formats { writer.WriteLine("[Difficulty]"); - writer.WriteLine(FormattableString.Invariant($"HPDrainRate: {beatmap.BeatmapInfo.BaseDifficulty.DrainRate}")); - writer.WriteLine(FormattableString.Invariant($"CircleSize: {beatmap.BeatmapInfo.BaseDifficulty.CircleSize}")); - writer.WriteLine(FormattableString.Invariant($"OverallDifficulty: {beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty}")); - writer.WriteLine(FormattableString.Invariant($"ApproachRate: {beatmap.BeatmapInfo.BaseDifficulty.ApproachRate}")); + writer.WriteLine(FormattableString.Invariant($"HPDrainRate: {beatmap.Difficulty.DrainRate}")); + writer.WriteLine(FormattableString.Invariant($"CircleSize: {beatmap.Difficulty.CircleSize}")); + writer.WriteLine(FormattableString.Invariant($"OverallDifficulty: {beatmap.Difficulty.OverallDifficulty}")); + writer.WriteLine(FormattableString.Invariant($"ApproachRate: {beatmap.Difficulty.ApproachRate}")); - // Taiko adjusts the slider multiplier (see: TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER) + // Taiko adjusts the slider multiplier (see: LEGACY_TAIKO_VELOCITY_MULTIPLIER) writer.WriteLine(beatmap.BeatmapInfo.RulesetID == 1 - ? FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / 1.4f}") - : FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier}")); + ? FormattableString.Invariant($"SliderMultiplier: {beatmap.Difficulty.SliderMultiplier / LEGACY_TAIKO_VELOCITY_MULTIPLIER}") + : FormattableString.Invariant($"SliderMultiplier: {beatmap.Difficulty.SliderMultiplier}")); - writer.WriteLine(FormattableString.Invariant($"SliderTickRate: {beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate}")); + writer.WriteLine(FormattableString.Invariant($"SliderTickRate: {beatmap.Difficulty.SliderTickRate}")); } private void handleEvents(TextWriter writer) @@ -167,6 +172,30 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine("[TimingPoints]"); + if (!(beatmap.ControlPointInfo is LegacyControlPointInfo)) + { + var legacyControlPoints = new LegacyControlPointInfo(); + + foreach (var point in beatmap.ControlPointInfo.AllControlPoints) + legacyControlPoints.Add(point.Time, point.DeepClone()); + + beatmap.ControlPointInfo = legacyControlPoints; + + SampleControlPoint lastRelevantSamplePoint = null; + + // iterate over hitobjects and pull out all required sample changes + foreach (var h in beatmap.HitObjects) + { + var hSamplePoint = h.SampleControlPoint; + + if (!hSamplePoint.IsRedundant(lastRelevantSamplePoint)) + { + legacyControlPoints.Add(hSamplePoint.Time, hSamplePoint); + lastRelevantSamplePoint = hSamplePoint; + } + } + } + foreach (var group in beatmap.ControlPointInfo.Groups) { var groupTimingPoint = group.ControlPoints.OfType().FirstOrDefault(); @@ -176,19 +205,19 @@ namespace osu.Game.Beatmaps.Formats { writer.Write(FormattableString.Invariant($"{groupTimingPoint.Time},")); writer.Write(FormattableString.Invariant($"{groupTimingPoint.BeatLength},")); - outputControlPointEffectsAt(groupTimingPoint.Time, true); + outputControlPointAt(groupTimingPoint.Time, true); } // Output any remaining effects as secondary non-timing control point. var difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(group.Time); writer.Write(FormattableString.Invariant($"{group.Time},")); writer.Write(FormattableString.Invariant($"{-100 / difficultyPoint.SpeedMultiplier},")); - outputControlPointEffectsAt(group.Time, false); + outputControlPointAt(group.Time, false); } - void outputControlPointEffectsAt(double time, bool isTimingPoint) + void outputControlPointAt(double time, bool isTimingPoint) { - var samplePoint = beatmap.ControlPointInfo.SamplePointAt(time); + var samplePoint = ((LegacyControlPointInfo)beatmap.ControlPointInfo).SamplePointAt(time); var effectPoint = beatmap.ControlPointInfo.EffectPointAt(time); // Apply the control point to a hit sample to uncover legacy properties (e.g. suffix) @@ -251,15 +280,12 @@ namespace osu.Game.Beatmaps.Formats switch (beatmap.BeatmapInfo.RulesetID) { case 0: + case 2: position = ((IHasPosition)hitObject).Position; break; - case 2: - position.X = ((IHasXPosition)hitObject).X; - break; - case 3: - int totalColumns = (int)Math.Max(1, beatmap.BeatmapInfo.BaseDifficulty.CircleSize); + int totalColumns = (int)Math.Max(1, beatmap.Difficulty.CircleSize); position.X = (int)Math.Ceiling(((IHasXPosition)hitObject).X * (512f / totalColumns)); break; } @@ -327,22 +353,22 @@ namespace osu.Game.Beatmaps.Formats { PathControlPoint point = pathData.Path.ControlPoints[i]; - if (point.Type.Value != null) + if (point.Type != null) { // We've reached a new (explicit) segment! // Explicit segments have a new format in which the type is injected into the middle of the control point string. // To preserve compatibility with osu-stable as much as possible, explicit segments with the same type are converted to use implicit segments by duplicating the control point. // One exception are consecutive perfect curves, which aren't supported in osu!stable and can lead to decoding issues if encoded as implicit segments - bool needsExplicitSegment = point.Type.Value != lastType || point.Type.Value == PathType.PerfectCurve; + bool needsExplicitSegment = point.Type != lastType || point.Type == PathType.PerfectCurve; // Another exception to this is when the last two control points of the last segment were duplicated. This is not a scenario supported by osu!stable. // Lazer does not add implicit segments for the last two control points of _any_ explicit segment, so an explicit segment is forced in order to maintain consistency with the decoder. if (i > 1) { // We need to use the absolute control point position to determine equality, otherwise floating point issues may arise. - Vector2 p1 = position + pathData.Path.ControlPoints[i - 1].Position.Value; - Vector2 p2 = position + pathData.Path.ControlPoints[i - 2].Position.Value; + Vector2 p1 = position + pathData.Path.ControlPoints[i - 1].Position; + Vector2 p2 = position + pathData.Path.ControlPoints[i - 2].Position; if ((int)p1.X == (int)p2.X && (int)p1.Y == (int)p2.Y) needsExplicitSegment = true; @@ -350,7 +376,7 @@ namespace osu.Game.Beatmaps.Formats if (needsExplicitSegment) { - switch (point.Type.Value) + switch (point.Type) { case PathType.Bezier: writer.Write("B|"); @@ -369,18 +395,18 @@ namespace osu.Game.Beatmaps.Formats break; } - lastType = point.Type.Value; + lastType = point.Type; } else { // New segment with the same type - duplicate the control point - writer.Write(FormattableString.Invariant($"{position.X + point.Position.Value.X}:{position.Y + point.Position.Value.Y}|")); + writer.Write(FormattableString.Invariant($"{position.X + point.Position.X}:{position.Y + point.Position.Y}|")); } } if (i != 0) { - writer.Write(FormattableString.Invariant($"{position.X + point.Position.Value.X}:{position.Y + point.Position.Value.Y}")); + writer.Write(FormattableString.Invariant($"{position.X + point.Position.X}:{position.Y + point.Position.Y}")); writer.Write(i != pathData.Path.ControlPoints.Count - 1 ? "|" : ","); } } diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index b39890084f..20080308f9 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -123,7 +123,7 @@ namespace osu.Game.Beatmaps.Formats { if (!(output is IHasComboColours tHasComboColours)) return; - tHasComboColours.AddComboColours(colour); + tHasComboColours.CustomComboColours.Add(colour); } else { diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index 6301c42deb..0f15e28c00 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -176,8 +176,8 @@ namespace osu.Game.Beatmaps.Formats case "L": { var startTime = Parsing.ParseDouble(split[1]); - var loopCount = Parsing.ParseInt(split[2]); - timelineGroup = storyboardSprite?.AddLoop(startTime, loopCount); + var repeatCount = Parsing.ParseInt(split[2]); + timelineGroup = storyboardSprite?.AddLoop(startTime, Math.Max(0, repeatCount - 1)); break; } diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 769b33009a..3f598cd1e5 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -4,12 +4,11 @@ using System.Collections.Generic; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; -using osu.Game.IO.Serialization; using osu.Game.Rulesets.Objects; namespace osu.Game.Beatmaps { - public interface IBeatmap : IJsonSerializable + public interface IBeatmap { /// /// This beatmap's info. @@ -21,6 +20,11 @@ namespace osu.Game.Beatmaps /// BeatmapMetadata Metadata { get; } + /// + /// This beatmap's difficulty settings. + /// + public BeatmapDifficulty Difficulty { get; set; } + /// /// The control points in this beatmap. /// diff --git a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs new file mode 100644 index 0000000000..339364d442 --- /dev/null +++ b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.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. + +#nullable enable + +namespace osu.Game.Beatmaps +{ + /// + /// A representation of all top-level difficulty settings for a beatmap. + /// + public interface IBeatmapDifficultyInfo + { + /// + /// The default value used for all difficulty settings except and . + /// + const float DEFAULT_DIFFICULTY = 5; + + /// + /// The drain rate of the associated beatmap. + /// + float DrainRate { get; } + + /// + /// The circle size of the associated beatmap. + /// + float CircleSize { get; } + + /// + /// The overall difficulty of the associated beatmap. + /// + float OverallDifficulty { get; } + + /// + /// The approach rate of the associated beatmap. + /// + float ApproachRate { get; } + + /// + /// The slider multiplier of the associated beatmap. + /// + double SliderMultiplier { get; } + + /// + /// The slider tick rate of the associated beatmap. + /// + double SliderTickRate { get; } + + /// + /// Maps a difficulty value [0, 10] to a two-piece linear range of values. + /// + /// The difficulty value to be mapped. + /// Minimum of the resulting range which will be achieved by a difficulty value of 0. + /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. + /// Maximum of the resulting range which will be achieved by a difficulty value of 10. + /// Value to which the difficulty value maps in the specified range. + static double DifficultyRange(double difficulty, double min, double mid, double max) + { + if (difficulty > 5) + return mid + (max - mid) * (difficulty - 5) / 5; + if (difficulty < 5) + return mid - (mid - min) * (5 - difficulty) / 5; + + return mid; + } + + /// + /// Maps a difficulty value [0, 10] to a two-piece linear range of values. + /// + /// The difficulty value to be mapped. + /// The values that define the two linear ranges. + /// + /// + /// od0 + /// Minimum of the resulting range which will be achieved by a difficulty value of 0. + /// + /// + /// od5 + /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. + /// + /// + /// od10 + /// Maximum of the resulting range which will be achieved by a difficulty value of 10. + /// + /// + /// + /// Value to which the difficulty value maps in the specified range. + static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range) + => DifficultyRange(difficulty, range.od0, range.od5, range.od10); + } +} diff --git a/osu.Game/Beatmaps/IBeatmapInfo.cs b/osu.Game/Beatmaps/IBeatmapInfo.cs new file mode 100644 index 0000000000..3d51c5d4b6 --- /dev/null +++ b/osu.Game/Beatmaps/IBeatmapInfo.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.Game.Database; +using osu.Game.Rulesets; + +#nullable enable + +namespace osu.Game.Beatmaps +{ + /// + /// A single beatmap difficulty. + /// + public interface IBeatmapInfo : IHasOnlineID + { + /// + /// The user-specified name given to this beatmap. + /// + string DifficultyName { get; } + + /// + /// The metadata representing this beatmap. May be shared between multiple beatmaps. + /// + IBeatmapMetadataInfo? Metadata { get; } + + /// + /// The difficulty settings for this beatmap. + /// + IBeatmapDifficultyInfo Difficulty { get; } + + /// + /// The beatmap set this beatmap is part of. + /// + IBeatmapSetInfo? BeatmapSet { get; } + + /// + /// The playable length in milliseconds of this beatmap. + /// + double Length { get; } + + /// + /// The most common BPM of this beatmap. + /// + double BPM { get; } + + /// + /// The SHA-256 hash representing this beatmap's contents. + /// + string Hash { get; } + + /// + /// MD5 is kept for legacy support (matching against replays etc.). + /// + string MD5Hash { get; } + + /// + /// The ruleset this beatmap was made for. + /// + IRulesetInfo Ruleset { get; } + + /// + /// The basic star rating for this beatmap (with no mods applied). + /// + double StarRating { get; } + } +} diff --git a/osu.Game/Beatmaps/IBeatmapMetadataInfo.cs b/osu.Game/Beatmaps/IBeatmapMetadataInfo.cs new file mode 100644 index 0000000000..55aee7d7bc --- /dev/null +++ b/osu.Game/Beatmaps/IBeatmapMetadataInfo.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +#nullable enable + +namespace osu.Game.Beatmaps +{ + /// + /// Metadata representing a beatmap. May be shared between multiple beatmap difficulties. + /// + public interface IBeatmapMetadataInfo : IEquatable + { + /// + /// The romanised title of this beatmap. + /// + string Title { get; } + + /// + /// The unicode title of this beatmap. + /// + string TitleUnicode { get; } + + /// + /// The romanised artist of this beatmap. + /// + string Artist { get; } + + /// + /// The unicode artist of this beatmap. + /// + string ArtistUnicode { get; } + + /// + /// The author of this beatmap. + /// + string Author { get; } // eventually should be linked to a persisted User. + + /// + /// The source of this beatmap. + /// + string Source { get; } + + /// + /// The tags of this beatmap. + /// + string Tags { get; } + + /// + /// The time in milliseconds to begin playing the track for preview purposes. + /// If -1, the track should begin playing at 40% of its length. + /// + int PreviewTime { get; } + + /// + /// The filename of the audio file consumed by this beatmap. + /// + string AudioFile { get; } + + /// + /// The filename of the background image file consumed by this beatmap. + /// + string BackgroundFile { get; } + + bool IEquatable.Equals(IBeatmapMetadataInfo? other) + { + if (other == null) + return false; + + return Title == other.Title + && TitleUnicode == other.TitleUnicode + && Artist == other.Artist + && ArtistUnicode == other.ArtistUnicode + && Author == other.Author + && Source == other.Source + && Tags == other.Tags + && PreviewTime == other.PreviewTime + && AudioFile == other.AudioFile + && BackgroundFile == other.BackgroundFile; + } + } +} diff --git a/osu.Game/Beatmaps/IBeatmapModelManager.cs b/osu.Game/Beatmaps/IBeatmapModelManager.cs new file mode 100644 index 0000000000..8c243c2b77 --- /dev/null +++ b/osu.Game/Beatmaps/IBeatmapModelManager.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.Database; + +namespace osu.Game.Beatmaps +{ + public interface IBeatmapModelManager : IModelManager + { + /// + /// Provide an online lookup queue component to handle populating online beatmap metadata. + /// + BeatmapOnlineLookupQueue OnlineLookupQueue { set; } + + /// + /// Provide a working beatmap cache, used to invalidate entries on changes. + /// + IWorkingBeatmapCache WorkingBeatmapCache { set; } + } +} diff --git a/osu.Game/Beatmaps/IBeatmapSetInfo.cs b/osu.Game/Beatmaps/IBeatmapSetInfo.cs new file mode 100644 index 0000000000..0cfb0c4242 --- /dev/null +++ b/osu.Game/Beatmaps/IBeatmapSetInfo.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Game.Database; + +#nullable enable + +namespace osu.Game.Beatmaps +{ + /// + /// A representation of a collection of beatmap difficulties, generally packaged as an ".osz" archive. + /// + public interface IBeatmapSetInfo : IHasOnlineID + { + /// + /// The date when this beatmap was imported. + /// + DateTimeOffset DateAdded { get; } + + /// + /// The best-effort metadata representing this set. In the case metadata differs between contained beatmaps, one is arbitrarily chosen. + /// + IBeatmapMetadataInfo? Metadata { get; } + + /// + /// All beatmaps contained in this set. + /// + IEnumerable Beatmaps { get; } + + /// + /// All files used by this set. + /// + IEnumerable Files { get; } + + /// + /// The maximum star difficulty of all beatmaps in this set. + /// + double MaxStarDifficulty { get; } + + /// + /// The maximum playable length in milliseconds of all beatmaps in this set. + /// + double MaxLength { get; } + + /// + /// The maximum BPM of all beatmaps in this set. + /// + double MaxBPM { get; } + } +} diff --git a/osu.Game/Beatmaps/IWorkingBeatmapCache.cs b/osu.Game/Beatmaps/IWorkingBeatmapCache.cs new file mode 100644 index 0000000000..3eb33f10d6 --- /dev/null +++ b/osu.Game/Beatmaps/IWorkingBeatmapCache.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Beatmaps +{ + public interface IWorkingBeatmapCache + { + /// + /// Retrieve a instance for the provided + /// + /// The beatmap to lookup. + /// A instance correlating to the provided . + WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo); + + /// + /// Invalidate a cache entry if it exists. + /// + /// The beatmap set info to invalidate any cached entries for. + void Invalidate(BeatmapSetInfo beatmapSetInfo); + + /// + /// Invalidate a cache entry if it exists. + /// + /// The beatmap info to invalidate any cached entries for. + void Invalidate(BeatmapInfo beatmapInfo); + } +} diff --git a/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs b/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs new file mode 100644 index 0000000000..ff0ca5ebe1 --- /dev/null +++ b/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using Newtonsoft.Json; +using osu.Framework.Bindables; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Beatmaps.Legacy +{ + public class LegacyControlPointInfo : ControlPointInfo + { + /// + /// All sound points. + /// + [JsonProperty] + public IBindableList SamplePoints => samplePoints; + + private readonly BindableList samplePoints = new BindableList(); + + /// + /// Finds the sound control point that is active at . + /// + /// 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); + + public override void Clear() + { + base.Clear(); + samplePoints.Clear(); + } + + protected override bool CheckAlreadyExisting(double time, ControlPoint newPoint) + { + if (newPoint is SampleControlPoint) + { + var existing = BinarySearch(SamplePoints, time); + return newPoint.IsRedundant(existing); + } + + return base.CheckAlreadyExisting(time, newPoint); + } + + protected override void GroupItemAdded(ControlPoint controlPoint) + { + if (controlPoint is SampleControlPoint typed) + samplePoints.Add(typed); + + base.GroupItemAdded(controlPoint); + } + + protected override void GroupItemRemoved(ControlPoint controlPoint) + { + if (controlPoint is SampleControlPoint typed) + samplePoints.Remove(typed); + + base.GroupItemRemoved(controlPoint); + } + } +} diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 662d24cc83..18adecb7aa 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -81,7 +81,7 @@ namespace osu.Game.Beatmaps /// The applicable . protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap); - public IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null, TimeSpan? timeout = null) + public virtual IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null, TimeSpan? timeout = null) { using (var cancellationSource = createCancellationTokenSource(timeout)) { @@ -119,15 +119,12 @@ namespace osu.Game.Beatmaps // Apply difficulty mods if (mods.Any(m => m is IApplicableToDifficulty)) { - converted.BeatmapInfo = converted.BeatmapInfo.Clone(); - converted.BeatmapInfo.BaseDifficulty = converted.BeatmapInfo.BaseDifficulty.Clone(); - foreach (var mod in mods.OfType()) { if (cancellationSource.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); - mod.ApplyToDifficulty(converted.BeatmapInfo.BaseDifficulty); + mod.ApplyToDifficulty(converted.Difficulty); } } @@ -146,7 +143,7 @@ namespace osu.Game.Beatmaps if (cancellationSource.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); - obj.ApplyDefaults(converted.ControlPointInfo, converted.BeatmapInfo.BaseDifficulty, cancellationSource.Token); + obj.ApplyDefaults(converted.ControlPointInfo, converted.Difficulty, cancellationSource.Token); } } catch (OperationCanceledException) @@ -327,7 +324,15 @@ namespace osu.Game.Beatmaps public bool SkinLoaded => skin.IsResultAvailable; public ISkin Skin => skin.Value; - protected abstract ISkin GetSkin(); + /// + /// Creates a new skin instance for this beatmap. + /// + /// + /// This should only be called externally in scenarios where it is explicitly desired to get a new instance of a skin + /// (e.g. for editing purposes, to avoid state pollution). + /// For standard reading purposes, should always be used directly. + /// + protected internal abstract ISkin GetSkin(); private readonly RecyclableLazy skin; diff --git a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs similarity index 52% rename from osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs rename to osu.Game/Beatmaps/WorkingBeatmapCache.cs index d78ffbbfb6..ad3e890b3a 100644 --- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -1,12 +1,18 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Lists; using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Statistics; using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.IO; @@ -15,8 +21,96 @@ using osu.Game.Storyboards; namespace osu.Game.Beatmaps { - public partial class BeatmapManager + public class WorkingBeatmapCache : IBeatmapResourceProvider, IWorkingBeatmapCache { + private readonly WeakList workingCache = new WeakList(); + + /// + /// A default representation of a WorkingBeatmap to use when no beatmap is available. + /// + public readonly WorkingBeatmap DefaultBeatmap; + + public BeatmapModelManager BeatmapManager { private get; set; } + + private readonly AudioManager audioManager; + private readonly IResourceStore resources; + private readonly LargeTextureStore largeTextureStore; + private readonly ITrackStore trackStore; + private readonly IResourceStore files; + + [CanBeNull] + private readonly GameHost host; + + public WorkingBeatmapCache([NotNull] AudioManager audioManager, IResourceStore resources, IResourceStore files, WorkingBeatmap defaultBeatmap = null, GameHost host = null) + { + DefaultBeatmap = defaultBeatmap; + + this.audioManager = audioManager; + this.resources = resources; + this.host = host; + this.files = files; + largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(files)); + trackStore = audioManager.GetTrackStore(files); + } + + public void Invalidate(BeatmapSetInfo info) + { + if (info.Beatmaps == null) return; + + foreach (var b in info.Beatmaps) + Invalidate(b); + } + + public void Invalidate(BeatmapInfo info) + { + lock (workingCache) + { + var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID); + if (working != null) + workingCache.Remove(working); + } + } + + public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo) + { + // if there are no files, presume the full beatmap info has not yet been fetched from the database. + if (beatmapInfo?.BeatmapSet?.Files.Count == 0) + { + int lookupId = beatmapInfo.ID; + beatmapInfo = BeatmapManager.QueryBeatmap(b => b.ID == lookupId); + } + + if (beatmapInfo?.BeatmapSet == null) + return DefaultBeatmap; + + lock (workingCache) + { + var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID); + if (working != null) + return working; + + beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata; + + workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this)); + + // best effort; may be higher than expected. + GlobalStatistics.Get(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count(); + + return working; + } + } + + #region IResourceStorageProvider + + TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; + ITrackStore IBeatmapResourceProvider.Tracks => trackStore; + AudioManager IStorageResourceProvider.AudioManager => audioManager; + IResourceStore IStorageResourceProvider.Files => files; + IResourceStore IStorageResourceProvider.Resources => resources; + IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore); + + #endregion + [ExcludeFromDynamicCompile] private class BeatmapManagerWorkingBeatmap : WorkingBeatmap { @@ -107,12 +201,14 @@ namespace osu.Game.Beatmaps { var decoder = Decoder.GetDecoder(stream); + var storyboardFilename = BeatmapSetInfo?.Files.FirstOrDefault(f => f.Filename.EndsWith(".osb", StringComparison.OrdinalIgnoreCase))?.Filename; + // todo: support loading from both set-wide storyboard *and* beatmap specific. - if (BeatmapSetInfo?.StoryboardFile == null) + if (string.IsNullOrEmpty(storyboardFilename)) storyboard = decoder.Decode(stream); else { - using (var secondaryStream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapSetInfo.StoryboardFile)))) + using (var secondaryStream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(storyboardFilename)))) storyboard = decoder.Decode(stream, secondaryStream); } } @@ -128,7 +224,7 @@ namespace osu.Game.Beatmaps return storyboard; } - protected override ISkin GetSkin() + protected internal override ISkin GetSkin() { try { diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index fe04c70d62..6f9d9cd8a8 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.IO; using osu.Game.IO.Legacy; using osu.Game.Overlays.Notifications; @@ -27,7 +28,7 @@ namespace osu.Game.Collections /// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the /// database backing the game. Going forward writing should be done in a similar way to other model stores. /// - public class CollectionManager : Component + public class CollectionManager : Component, IPostNotifications { /// /// Database version in stable-compatible YYYYMMDD format. @@ -106,9 +107,6 @@ namespace osu.Game.Collections backgroundSave(); }); - /// - /// Set an endpoint for notifications to be posted to. - /// public Action PostNotification { protected get; set; } /// diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index 680fec904f..95fbfa0f86 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Game.Audio.Effects; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -20,6 +22,8 @@ namespace osu.Game.Collections private const double enter_duration = 500; private const double exit_duration = 200; + private AudioFilter lowPassFilter; + [Resolved(CanBeNull = true)] private CollectionManager collectionManager { get; set; } @@ -36,7 +40,7 @@ namespace osu.Game.Collections } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, AudioManager audio) { Children = new Drawable[] { @@ -108,7 +112,8 @@ namespace osu.Game.Collections }, } } - } + }, + lowPassFilter = new AudioFilter(audio.TrackMixer) }; } @@ -116,6 +121,7 @@ namespace osu.Game.Collections { base.PopIn(); + lowPassFilter.CutoffTo(300, 100, Easing.OutCubic); this.FadeIn(enter_duration, Easing.OutQuint); this.ScaleTo(0.9f).Then().ScaleTo(1f, enter_duration, Easing.OutQuint); } @@ -124,6 +130,8 @@ namespace osu.Game.Collections { base.PopOut(); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); + this.FadeOut(exit_duration, Easing.OutQuint); this.ScaleTo(0.9f, exit_duration); diff --git a/osu.Game/Configuration/DatabasedConfigManager.cs b/osu.Game/Configuration/DatabasedConfigManager.cs deleted file mode 100644 index b3783b45a8..0000000000 --- a/osu.Game/Configuration/DatabasedConfigManager.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Bindables; -using osu.Framework.Configuration; -using osu.Game.Rulesets; - -namespace osu.Game.Configuration -{ - public abstract class DatabasedConfigManager : ConfigManager - where TLookup : struct, Enum - { - private readonly SettingsStore settings; - - private readonly int? variant; - - private List databasedSettings; - - private readonly RulesetInfo ruleset; - - private bool legacySettingsExist; - - protected DatabasedConfigManager(SettingsStore settings, RulesetInfo ruleset = null, int? variant = null) - { - this.settings = settings; - this.ruleset = ruleset; - this.variant = variant; - - Load(); - - InitialiseDefaults(); - } - - protected override void PerformLoad() - { - databasedSettings = settings.Query(ruleset?.ID, variant); - legacySettingsExist = databasedSettings.Any(s => int.TryParse(s.Key, out _)); - } - - protected override bool PerformSave() - { - lock (dirtySettings) - { - foreach (var setting in dirtySettings) - settings.Update(setting); - dirtySettings.Clear(); - } - - return true; - } - - private readonly List dirtySettings = new List(); - - protected override void AddBindable(TLookup lookup, Bindable bindable) - { - base.AddBindable(lookup, bindable); - - if (legacySettingsExist) - { - var legacySetting = databasedSettings.Find(s => s.Key == ((int)(object)lookup).ToString()); - - if (legacySetting != null) - { - bindable.Parse(legacySetting.Value); - settings.Delete(legacySetting); - } - } - - var setting = databasedSettings.Find(s => s.Key == lookup.ToString()); - - if (setting != null) - { - bindable.Parse(setting.Value); - } - else - { - settings.Update(setting = new DatabasedSetting - { - Key = lookup.ToString(), - Value = bindable.Value, - RulesetID = ruleset?.ID, - Variant = variant, - }); - - databasedSettings.Add(setting); - } - - bindable.ValueChanged += b => - { - setting.Value = b.NewValue; - - lock (dirtySettings) - { - if (!dirtySettings.Contains(setting)) - dirtySettings.Add(setting); - } - }; - } - } -} diff --git a/osu.Game/Configuration/DatabasedSetting.cs b/osu.Game/Configuration/DatabasedSetting.cs index f5c92b3029..fe1d51d57f 100644 --- a/osu.Game/Configuration/DatabasedSetting.cs +++ b/osu.Game/Configuration/DatabasedSetting.cs @@ -7,7 +7,7 @@ using osu.Game.Database; namespace osu.Game.Configuration { [Table("Settings")] - public class DatabasedSetting : IHasPrimaryKey + public class DatabasedSetting : IHasPrimaryKey // can be removed 20220315. { public int ID { get; set; } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 60a0d5a0ac..9b0d7f51da 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -201,6 +201,8 @@ namespace osu.Game.Configuration public Func LookupKeyBindings { get; set; } } + // IMPORTANT: These are used in user configuration files. + // The naming of these keys should not be changed once they are deployed in a release, unless migration logic is also added. public enum OsuSetting { Ruleset, diff --git a/osu.Game/Configuration/RandomSelectAlgorithm.cs b/osu.Game/Configuration/RandomSelectAlgorithm.cs index 8d0c87374f..b22f2ae485 100644 --- a/osu.Game/Configuration/RandomSelectAlgorithm.cs +++ b/osu.Game/Configuration/RandomSelectAlgorithm.cs @@ -10,7 +10,7 @@ namespace osu.Game.Configuration [Description("Never repeat")] RandomPermutation, - [Description("Random")] + [Description("True Random")] Random } } diff --git a/osu.Game/Configuration/RealmRulesetSetting.cs b/osu.Game/Configuration/RealmRulesetSetting.cs new file mode 100644 index 0000000000..07e56ad8dd --- /dev/null +++ b/osu.Game/Configuration/RealmRulesetSetting.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Database; +using Realms; + +#nullable enable + +namespace osu.Game.Configuration +{ + [MapTo(@"RulesetSetting")] + public class RealmRulesetSetting : RealmObject, IHasGuidPrimaryKey + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + [Indexed] + public int RulesetID { get; set; } + + [Indexed] + public int Variant { get; set; } + + [Required] + public string Key { get; set; } = string.Empty; + + [Required] + public string Value { get; set; } = string.Empty; + + public override string ToString() => $"{Key} => {Value}"; + } +} diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index f373e59417..5db502804d 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -4,6 +4,7 @@ #nullable enable using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -167,9 +168,21 @@ namespace osu.Game.Configuration } } + private static readonly ConcurrentDictionary property_info_cache = new ConcurrentDictionary(); + public static IEnumerable<(SettingSourceAttribute, PropertyInfo)> GetSettingsSourceProperties(this object obj) { - foreach (var property in obj.GetType().GetProperties(BindingFlags.GetProperty | BindingFlags.Public | BindingFlags.Instance)) + var type = obj.GetType(); + + if (!property_info_cache.TryGetValue(type, out var properties)) + property_info_cache[type] = properties = getSettingsSourceProperties(type).ToArray(); + + return properties; + } + + private static IEnumerable<(SettingSourceAttribute, PropertyInfo)> getSettingsSourceProperties(Type type) + { + foreach (var property in type.GetProperties(BindingFlags.GetProperty | BindingFlags.Public | BindingFlags.Instance)) { var attr = property.GetCustomAttribute(true); diff --git a/osu.Game/Configuration/SettingsStore.cs b/osu.Game/Configuration/SettingsStore.cs index 86e84b0732..2bba20fb09 100644 --- a/osu.Game/Configuration/SettingsStore.cs +++ b/osu.Game/Configuration/SettingsStore.cs @@ -1,46 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; using osu.Game.Database; namespace osu.Game.Configuration { - public class SettingsStore : DatabaseBackedStore + public class SettingsStore { - public event Action SettingChanged; + // this class mostly exists as a wrapper to avoid breaking the ruleset API (see usage in RulesetConfigManager). + // it may cease to exist going forward, depending on how the structure of the config data layer changes. - public SettingsStore(DatabaseContextFactory contextFactory) - : base(contextFactory) + public readonly RealmContextFactory Realm; + + public SettingsStore(RealmContextFactory realmFactory) { - } - - /// - /// 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().DatabasedSetting.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList(); - - public void Update(DatabasedSetting setting) - { - using (ContextFactory.GetForWrite()) - { - var newValue = setting.Value; - Refresh(ref setting); - setting.Value = newValue; - } - - SettingChanged?.Invoke(); - } - - public void Delete(DatabasedSetting setting) - { - using (var usage = ContextFactory.GetForWrite()) - usage.Context.Remove(setting); + Realm = realmFactory; } } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index f72a43fa01..c235fc7728 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -30,7 +30,7 @@ namespace osu.Game.Database /// /// The model type. /// The associated file join type. - public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager + public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager, IModelFileManager, IPostImports where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete where TFileModel : class, INamedFileInfo, new() { @@ -57,9 +57,6 @@ namespace osu.Game.Database /// private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager)); - /// - /// Set an endpoint for notifications to be posted to. - /// public Action PostNotification { protected get; set; } /// @@ -119,7 +116,7 @@ namespace osu.Game.Database /// One or more archive locations on disk. public Task Import(params string[] paths) { - var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + var notification = new ImportProgressNotification(); PostNotification?.Invoke(notification); @@ -128,20 +125,20 @@ namespace osu.Game.Database public Task Import(params ImportTask[] tasks) { - var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + var notification = new ImportProgressNotification(); PostNotification?.Invoke(notification); return Import(notification, tasks); } - protected async Task> Import(ProgressNotification notification, params ImportTask[] tasks) + public async Task>> Import(ProgressNotification notification, params ImportTask[] tasks) { if (tasks.Length == 0) { notification.CompletionText = $"No {HumanisedModelName}s were found to import!"; notification.State = ProgressNotificationState.Completed; - return Enumerable.Empty(); + return Enumerable.Empty>(); } notification.Progress = 0; @@ -149,7 +146,7 @@ namespace osu.Game.Database int current = 0; - var imported = new List(); + var imported = new List>(); bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size; @@ -200,15 +197,15 @@ namespace osu.Game.Database else { notification.CompletionText = imported.Count == 1 - ? $"Imported {imported.First()}!" + ? $"Imported {imported.First().Value}!" : $"Imported {imported.Count} {HumanisedModelName}s!"; - if (imported.Count > 0 && PresentImport != null) + if (imported.Count > 0 && PostImport != null) { notification.CompletionText += " Click to view."; notification.CompletionClickAction = () => { - PresentImport?.Invoke(imported); + PostImport?.Invoke(imported); return true; }; } @@ -227,11 +224,11 @@ namespace osu.Game.Database /// Whether this is a low priority import. /// An optional cancellation token. /// The imported model, if successful. - internal async Task Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) + public async Task> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - TModel import; + ILive import; using (ArchiveReader reader = task.GetReader()) import = await Import(reader, lowPriority, cancellationToken).ConfigureAwait(false); @@ -246,16 +243,13 @@ namespace osu.Game.Database } catch (Exception e) { - LogForModel(import, $@"Could not delete original file after import ({task})", e); + LogForModel(import?.Value, $@"Could not delete original file after import ({task})", e); } return import; } - /// - /// Fired when the user requests to view the resulting import. - /// - public Action> PresentImport; + public Action>> PostImport { protected get; set; } /// /// Silently import an item from an . @@ -263,7 +257,7 @@ namespace osu.Game.Database /// The archive to be imported. /// Whether this is a low priority import. /// An optional cancellation token. - public Task Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) + public Task> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -274,7 +268,7 @@ namespace osu.Game.Database model = CreateModel(archive); if (model == null) - return Task.FromResult(null); + return Task.FromResult>(new EntityFrameworkLive(null)); } catch (TaskCanceledException) { @@ -349,12 +343,10 @@ namespace osu.Game.Database /// An optional archive to use for model population. /// Whether this is a low priority import. /// An optional cancellation token. - public virtual async Task Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () => + public virtual async Task> Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () => { cancellationToken.ThrowIfCancellationRequested(); - delayEvents(); - bool checkedExisting = false; TModel existing = null; @@ -377,7 +369,7 @@ namespace osu.Game.Database { LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); Undelete(existing); - return existing; + return existing.ToEntityFrameworkLive(); } LogForModel(item, @"Found existing (optimised) but failed pre-check."); @@ -394,6 +386,8 @@ namespace osu.Game.Database } } + delayEvents(); + try { LogForModel(item, @"Beginning import..."); @@ -421,7 +415,7 @@ namespace osu.Game.Database // existing item will be used; rollback new import and exit early. rollback(); flushEvents(true); - return existing; + return existing.ToEntityFrameworkLive(); } LogForModel(item, @"Found existing but failed re-use check."); @@ -454,7 +448,7 @@ namespace osu.Game.Database } flushEvents(true); - return item; + return item.ToEntityFrameworkLive(); }, cancellationToken, TaskCreationOptions.HideScheduler, lowPriority ? import_scheduler_low_priority : import_scheduler).Unwrap().ConfigureAwait(false); /// @@ -479,7 +473,7 @@ namespace osu.Game.Database /// /// The item to export. /// The output stream to export to. - protected virtual void ExportModelTo(TModel model, Stream outputStream) + public virtual void ExportModelTo(TModel model, Stream outputStream) { using (var archive = ZipArchive.Create()) { @@ -745,9 +739,6 @@ namespace osu.Game.Database /// Whether to perform deletion. protected virtual bool ShouldDeleteArchive(string path) => false; - /// - /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. - /// public Task ImportFromStableAsync(StableStorage stableStorage) { var storage = PrepareStableStorage(stableStorage); @@ -805,8 +796,19 @@ 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); + public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, ModelStore.ConsumableItems.Where(m => !m.DeletePending)); + /// - /// Whether inport can be skipped after finding an existing import early in the process. + /// Performs implementation specific comparisons to determine whether a given model is present in the local store. + /// + /// The whose existence needs to be checked. + /// The usable items present in the store. + /// Whether the exists. + protected virtual bool CheckLocalAvailability(TModel model, IQueryable items) + => model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any()); + + /// + /// Whether import can be skipped after finding an existing import early in the process. /// Only valid when is not overridden. /// /// The existing model. @@ -841,7 +843,7 @@ namespace osu.Game.Database private DbSet queryModel() => ContextFactory.Get().Set(); - protected virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}"; + public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}"; #region Event handling / delaying diff --git a/osu.Game/Database/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs index 1cceb59b11..94fa967d72 100644 --- a/osu.Game/Database/DatabaseContextFactory.cs +++ b/osu.Game/Database/DatabaseContextFactory.cs @@ -13,7 +13,7 @@ namespace osu.Game.Database { private readonly Storage storage; - private const string database_name = @"client"; + private const string database_name = @"client.db"; private ThreadLocal threadContexts; @@ -139,7 +139,7 @@ namespace osu.Game.Database threadContexts = new ThreadLocal(CreateContext, true); } - protected virtual OsuDbContext CreateContext() => new OsuDbContext(storage.GetDatabaseConnectionString(database_name)) + protected virtual OsuDbContext CreateContext() => new OsuDbContext(CreateDatabaseConnectionString(database_name, storage)) { Database = { AutoTransactionsEnabled = false } }; @@ -152,7 +152,7 @@ namespace osu.Game.Database try { - storage.DeleteDatabase(database_name); + storage.Delete(database_name); } catch { @@ -163,10 +163,15 @@ namespace osu.Game.Database public void FlushConnections() { - foreach (var context in threadContexts.Values) - context.Dispose(); + if (threadContexts != null) + { + foreach (var context in threadContexts.Values) + context.Dispose(); + } recycleThreadContexts(); } + + public static string CreateDatabaseConnectionString(string filename, Storage storage) => string.Concat("Data Source=", storage.GetFullPath($@"{filename}", true)); } } diff --git a/osu.Game/Database/EntityFrameworkLive.cs b/osu.Game/Database/EntityFrameworkLive.cs new file mode 100644 index 0000000000..1d7b53911a --- /dev/null +++ b/osu.Game/Database/EntityFrameworkLive.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; + +namespace osu.Game.Database +{ + public class EntityFrameworkLive : ILive where T : class + { + public EntityFrameworkLive(T item) + { + Value = item; + } + + public Guid ID => throw new InvalidOperationException(); + + public void PerformRead(Action perform) + { + perform(Value); + } + + public TReturn PerformRead(Func perform) + { + return perform(Value); + } + + public void PerformWrite(Action perform) + { + perform(Value); + } + + public T Value { get; } + } +} diff --git a/osu.Game/Database/EntityFrameworkLiveExtensions.cs b/osu.Game/Database/EntityFrameworkLiveExtensions.cs new file mode 100644 index 0000000000..cd0673675e --- /dev/null +++ b/osu.Game/Database/EntityFrameworkLiveExtensions.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.Database +{ + public static class EntityFrameworkLiveExtensions + { + public static ILive ToEntityFrameworkLive(this T item) + where T : class + { + return new EntityFrameworkLive(item); + } + } +} diff --git a/osu.Game/Database/IHasGuidPrimaryKey.cs b/osu.Game/Database/IHasGuidPrimaryKey.cs index c9cd9b257a..f52dc5c8ef 100644 --- a/osu.Game/Database/IHasGuidPrimaryKey.cs +++ b/osu.Game/Database/IHasGuidPrimaryKey.cs @@ -11,6 +11,6 @@ namespace osu.Game.Database { [JsonIgnore] [PrimaryKey] - Guid ID { get; set; } + Guid ID { get; } } } diff --git a/osu.Game/Database/IHasOnlineID.cs b/osu.Game/Database/IHasOnlineID.cs new file mode 100644 index 0000000000..c55c461d2d --- /dev/null +++ b/osu.Game/Database/IHasOnlineID.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. + +#nullable enable + +namespace osu.Game.Database +{ + public interface IHasOnlineID + { + /// + /// The server-side ID representing this instance, if one exists. + /// + int? OnlineID { get; } + } +} diff --git a/osu.Game/Database/IHasRealmFiles.cs b/osu.Game/Database/IHasRealmFiles.cs new file mode 100644 index 0000000000..024d9f2a89 --- /dev/null +++ b/osu.Game/Database/IHasRealmFiles.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Models; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// A model that contains a list of files it is responsible for. + /// + public interface IHasRealmFiles + { + IList Files { get; } + + string Hash { get; set; } + } +} diff --git a/osu.Game/Database/ILive.cs b/osu.Game/Database/ILive.cs new file mode 100644 index 0000000000..9359b09eaf --- /dev/null +++ b/osu.Game/Database/ILive.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Database +{ + /// + /// A wrapper to provide access to database backed classes in a thread-safe manner. + /// + /// The databased type. + public interface ILive where T : class // TODO: Add IHasGuidPrimaryKey once we don't need EF support any more. + { + Guid ID { get; } + + /// + /// Perform a read operation on this live object. + /// + /// The action to perform. + void PerformRead(Action perform); + + /// + /// Perform a read operation on this live object. + /// + /// The action to perform. + TReturn PerformRead(Func perform); + + /// + /// Perform a write operation on this live object. + /// + /// The action to perform. + void PerformWrite(Action perform); + + /// + /// Resolve the value of this instance on the current thread's context. + /// + /// + /// After resolving the data should not be passed between threads. + /// + T Value { get; } + } +} diff --git a/osu.Game/Database/IModelDownloader.cs b/osu.Game/Database/IModelDownloader.cs index 0cb633280e..a5573b2190 100644 --- a/osu.Game/Database/IModelDownloader.cs +++ b/osu.Game/Database/IModelDownloader.cs @@ -11,7 +11,7 @@ namespace osu.Game.Database /// Represents a that can download new models from an external source. /// /// The model type. - public interface IModelDownloader : IModelManager + public interface IModelDownloader : IPostNotifications where TModel : class { /// @@ -26,13 +26,6 @@ namespace osu.Game.Database /// IBindable>> DownloadFailed { get; } - /// - /// Checks whether a given is already available in the local store. - /// - /// The whose existence needs to be checked. - /// Whether the exists. - bool IsAvailableLocally(TModel model); - /// /// Begin a download for the requested . /// diff --git a/osu.Game/Database/IModelFileManager.cs b/osu.Game/Database/IModelFileManager.cs new file mode 100644 index 0000000000..c74b945eb7 --- /dev/null +++ b/osu.Game/Database/IModelFileManager.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; + +namespace osu.Game.Database +{ + public interface IModelFileManager + where TModel : class + where TFileModel : class + { + /// + /// Replace an existing file with a new version. + /// + /// The item to operate on. + /// The existing file to be replaced. + /// The new file contents. + /// An optional filename for the new file. Will use the previous filename if not specified. + void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null); + + /// + /// Delete an existing file. + /// + /// The item to operate on. + /// The existing file to be deleted. + void DeleteFile(TModel model, TFileModel file); + + /// + /// Add a new file. + /// + /// The item to operate on. + /// The new file contents. + /// The filename for the new file. + void AddFile(TModel model, Stream contents, string filename); + } +} diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs new file mode 100644 index 0000000000..8e658cb0f5 --- /dev/null +++ b/osu.Game/Database/IModelImporter.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.Threading; +using System.Threading.Tasks; +using osu.Game.IO.Archives; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Database +{ + /// + /// A class which handles importing of associated models to the game store. + /// + /// The model type. + public interface IModelImporter : IPostNotifications + where TModel : class + { + /// + /// Import one or more items from filesystem . + /// + /// + /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority. + /// This will post notifications tracking progress. + /// + /// One or more archive locations on disk. + Task Import(params string[] paths); + + Task Import(params ImportTask[] tasks); + + Task>> Import(ProgressNotification notification, params ImportTask[] tasks); + + /// + /// Import one from the filesystem and delete the file on success. + /// Note that this bypasses the UI flow and should only be used for special cases or testing. + /// + /// The containing data about the to import. + /// Whether this is a low priority import. + /// An optional cancellation token. + /// The imported model, if successful. + Task> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default); + + /// + /// Silently import an item from an . + /// + /// The archive to be imported. + /// Whether this is a low priority import. + /// An optional cancellation token. + Task> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default); + + /// + /// Silently import an item from a . + /// + /// The model to be imported. + /// An optional archive to use for model population. + /// Whether this is a low priority import. + /// An optional cancellation token. + Task> Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default); + + /// + /// A user displayable name for the model type associated with this manager. + /// + string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}"; + } +} diff --git a/osu.Game/Database/IModelManager.cs b/osu.Game/Database/IModelManager.cs index 7f7e5565f1..f5e401cdfb 100644 --- a/osu.Game/Database/IModelManager.cs +++ b/osu.Game/Database/IModelManager.cs @@ -1,8 +1,12 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; using osu.Framework.Bindables; +using osu.Game.IO; namespace osu.Game.Database { @@ -10,11 +14,77 @@ namespace osu.Game.Database /// Represents a model manager that publishes events when s are added or removed. /// /// The model type. - public interface IModelManager + public interface IModelManager : IModelImporter where TModel : class { + /// + /// A bindable which contains a weak reference to the last item that was updated. + /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. + /// IBindable> ItemUpdated { get; } + /// + /// A bindable which contains a weak reference to the last item that was removed. + /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. + /// IBindable> ItemRemoved { get; } + + /// + /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. + /// + Task ImportFromStableAsync(StableStorage stableStorage); + + /// + /// Exports an item to a legacy (.zip based) package. + /// + /// The item to export. + void Export(TModel item); + + /// + /// Exports an item to the given output stream. + /// + /// The item to export. + /// The output stream to export to. + void ExportModelTo(TModel model, Stream outputStream); + + /// + /// Perform an update of the specified item. + /// TODO: Support file additions/removals. + /// + /// The item to update. + void Update(TModel item); + + /// + /// Delete an item from the manager. + /// Is a no-op for already deleted items. + /// + /// The item to delete. + /// false if no operation was performed + bool Delete(TModel item); + + /// + /// Delete multiple items. + /// This will post notifications tracking progress. + /// + void Delete(List items, bool silent = false); + + /// + /// Restore multiple items that were previously deleted. + /// This will post notifications tracking progress. + /// + void Undelete(List items, bool silent = false); + + /// + /// Restore an item that was previously deleted. Is a no-op if the item is not in a deleted state, or has its protected flag set. + /// + /// The item to restore + void Undelete(TModel item); + + /// + /// Checks whether a given is already available in the local store. + /// + /// The whose existence needs to be checked. + /// Whether the exists. + bool IsAvailableLocally(TModel model); } } diff --git a/osu.Game/Database/INamedFile.cs b/osu.Game/Database/INamedFile.cs new file mode 100644 index 0000000000..2bd45d4e42 --- /dev/null +++ b/osu.Game/Database/INamedFile.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Models; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// Represents a join model which gives a filename and scope to a . + /// + public interface INamedFile + { + string Filename { get; set; } + + RealmFile File { get; set; } + } +} diff --git a/osu.Game/Database/INamedFileUsage.cs b/osu.Game/Database/INamedFileUsage.cs new file mode 100644 index 0000000000..e558ffe0fb --- /dev/null +++ b/osu.Game/Database/INamedFileUsage.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.IO; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// A usage of a file, with a local filename attached. + /// + public interface INamedFileUsage + { + /// + /// The underlying file on disk. + /// + IFileInfo File { get; } + + /// + /// The filename for this usage. + /// + string Filename { get; } + } +} diff --git a/osu.Game/Database/IPostImports.cs b/osu.Game/Database/IPostImports.cs new file mode 100644 index 0000000000..f09285089a --- /dev/null +++ b/osu.Game/Database/IPostImports.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; + +namespace osu.Game.Database +{ + public interface IPostImports + where TModel : class + { + /// + /// Fired when the user requests to view the resulting import. + /// + public Action>> PostImport { set; } + } +} diff --git a/osu.Game/Database/IPostNotifications.cs b/osu.Game/Database/IPostNotifications.cs new file mode 100644 index 0000000000..d4fd64e79e --- /dev/null +++ b/osu.Game/Database/IPostNotifications.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Database +{ + public interface IPostNotifications + { + /// + /// And action which will be fired when a notification should be presented to the user. + /// + public Action PostNotification { set; } + } +} diff --git a/osu.Game/Database/IRealmFactory.cs b/osu.Game/Database/IRealmFactory.cs index c79442134c..a957424584 100644 --- a/osu.Game/Database/IRealmFactory.cs +++ b/osu.Game/Database/IRealmFactory.cs @@ -13,15 +13,8 @@ namespace osu.Game.Database Realm Context { get; } /// - /// Get a fresh context for read usage. + /// Create a new realm context for use on the current thread. /// - 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(); + Realm CreateContext(); } } diff --git a/osu.Game/Database/ImportProgressNotification.cs b/osu.Game/Database/ImportProgressNotification.cs new file mode 100644 index 0000000000..aaee3e117f --- /dev/null +++ b/osu.Game/Database/ImportProgressNotification.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.Overlays.Notifications; + +namespace osu.Game.Database +{ + public class ImportProgressNotification : ProgressNotification + { + public ImportProgressNotification() + { + State = ProgressNotificationState.Active; + } + } +} diff --git a/osu.Game/Database/DownloadableArchiveModelManager.cs b/osu.Game/Database/ModelDownloader.cs similarity index 64% rename from osu.Game/Database/DownloadableArchiveModelManager.cs rename to osu.Game/Database/ModelDownloader.cs index da3144e8d0..e613b39b6b 100644 --- a/osu.Game/Database/DownloadableArchiveModelManager.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -1,29 +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 Humanizer; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Game.Online.API; -using osu.Game.Overlays.Notifications; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Humanizer; using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Online.API; +using osu.Game.Overlays.Notifications; namespace osu.Game.Database { - /// - /// An that has the ability to download models using an and - /// import them into the store. - /// - /// The model type. - /// The associated file join type. - public abstract class DownloadableArchiveModelManager : ArchiveModelManager, IModelDownloader - where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable - where TFileModel : class, INamedFileInfo, new() + public abstract class ModelDownloader : IModelDownloader + where TModel : class, IHasPrimaryKey, ISoftDelete, IEquatable { + public Action PostNotification { protected get; set; } + public IBindable>> DownloadBegan => downloadBegan; private readonly Bindable>> downloadBegan = new Bindable>>(); @@ -32,18 +27,15 @@ namespace osu.Game.Database private readonly Bindable>> downloadFailed = new Bindable>>(); + private readonly IModelManager modelManager; private readonly IAPIProvider api; private readonly List> currentDownloads = new List>(); - private readonly MutableDatabaseBackedStoreWithFileIncludes modelStore; - - protected DownloadableArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, IAPIProvider api, MutableDatabaseBackedStoreWithFileIncludes modelStore, - IIpcHost importHost = null) - : base(storage, contextFactory, modelStore, importHost) + protected ModelDownloader(IModelManager modelManager, IAPIProvider api, IIpcHost importHost = null) { + this.modelManager = modelManager; this.api = api; - this.modelStore = modelStore; } /// @@ -54,12 +46,6 @@ namespace osu.Game.Database /// The request object. protected abstract ArchiveDownloadRequest CreateDownloadRequest(TModel model, bool minimiseDownloadSize); - /// - /// Begin a download for the requested . - /// - /// The to be downloaded. - /// Whether this download should be optimised for slow connections. Generally means extras are not included in the download bundle. - /// Whether the download was started. public bool Download(TModel model, bool minimiseDownloadSize = false) { if (!canDownload(model)) return false; @@ -82,7 +68,7 @@ namespace osu.Game.Database Task.Factory.StartNew(async () => { // This gets scheduled back to the update thread, but we want the import to run in the background. - var imported = await Import(notification, new ImportTask(filename)).ConfigureAwait(false); + var imported = await modelManager.Import(notification, new ImportTask(filename)).ConfigureAwait(false); // for now a failed import will be marked as a failed download for simplicity. if (!imported.Any()) @@ -117,21 +103,10 @@ namespace osu.Game.Database notification.State = ProgressNotificationState.Cancelled; if (!(error is OperationCanceledException)) - Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!"); + Logger.Error(error, $"{modelManager.HumanisedModelName.Titleize()} download failed!"); } } - public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, modelStore.ConsumableItems.Where(m => !m.DeletePending)); - - /// - /// Performs implementation specific comparisons to determine whether a given model is present in the local store. - /// - /// The whose existence needs to be checked. - /// The usable items present in the store. - /// Whether the exists. - protected virtual bool CheckLocalAvailability(TModel model, IQueryable items) - => model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any()); - public ArchiveDownloadRequest GetExistingDownload(TModel model) => currentDownloads.Find(r => r.Model.Equals(model)); private bool canDownload(TModel model) => GetExistingDownload(model) == null && api != null; diff --git a/osu.Game/Database/MutableDatabaseBackedStore.cs b/osu.Game/Database/MutableDatabaseBackedStore.cs index c9d0c4bc41..b0feb7bb78 100644 --- a/osu.Game/Database/MutableDatabaseBackedStore.cs +++ b/osu.Game/Database/MutableDatabaseBackedStore.cs @@ -36,6 +36,11 @@ namespace osu.Game.Database /// public IQueryable ConsumableItems => AddIncludesForConsumption(ContextFactory.Get().Set()); + /// + /// Access barebones items with no includes. + /// + public IQueryable Items => ContextFactory.Get().Set(); + /// /// Add a to the database. /// diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs index e0c0f56cb3..1d8322aadd 100644 --- a/osu.Game/Database/OsuDbContext.cs +++ b/osu.Game/Database/OsuDbContext.cs @@ -12,7 +12,6 @@ using osu.Game.Configuration; using osu.Game.IO; using osu.Game.Rulesets; using osu.Game.Scoring; -using DatabasedKeyBinding = osu.Game.Input.Bindings.DatabasedKeyBinding; using LogLevel = Microsoft.Extensions.Logging.LogLevel; using osu.Game.Skinning; @@ -24,14 +23,13 @@ namespace osu.Game.Database public DbSet BeatmapDifficulty { get; set; } public DbSet BeatmapMetadata { get; set; } public DbSet BeatmapSetInfo { 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; } + public DbSet DatabasedSetting { get; set; } private readonly string connectionString; @@ -77,6 +75,9 @@ namespace osu.Game.Database { cmd.CommandText = "PRAGMA journal_mode=WAL;"; cmd.ExecuteNonQuery(); + + cmd.CommandText = "PRAGMA foreign_keys=OFF;"; + cmd.ExecuteNonQuery(); } } catch @@ -135,11 +136,6 @@ namespace osu.Game.Database modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); modelBuilder.Entity().HasIndex(b => b.DeletePending); - modelBuilder.Entity().HasIndex(b => new { b.RulesetID, b.Variant }); - modelBuilder.Entity().HasIndex(b => b.IntAction); - modelBuilder.Entity().Ignore(b => b.KeyCombination); - modelBuilder.Entity().Ignore(b => b.Action); - modelBuilder.Entity().HasIndex(b => new { b.RulesetID, b.Variant }); modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs index fb5e2faff8..c3810eb441 100644 --- a/osu.Game/Database/RealmContextFactory.cs +++ b/osu.Game/Database/RealmContextFactory.cs @@ -4,76 +4,122 @@ using System; 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; +#nullable enable + namespace osu.Game.Database { + /// + /// A factory which provides both the main (update thread bound) realm context and creates contexts for async usage. + /// public class RealmContextFactory : Component, IRealmFactory { private readonly Storage storage; - private const string database_name = @"client"; + /// + /// The filename of this realm. + /// + public readonly string Filename; private const int schema_version = 6; /// - /// Lock object which is held for the duration of a write operation (via ). + /// Lock object which is held during sections, blocking context creation during blocking periods. /// - private readonly object writeLock = new object(); + private readonly SemaphoreSlim contextCreationLock = new SemaphoreSlim(1); - /// - /// 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 Realm context; + private readonly object contextLock = new object(); + private Realm? context; public Realm Context { get { - if (context == null) + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException($"Use {nameof(CreateContext)} when performing realm operations from a non-update thread"); + + lock (contextLock) { - context = createContext(); - Logger.Log($"Opened realm \"{context.Config.DatabasePath}\" at version {context.Config.SchemaVersion}"); + 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; } - - // creating a context will ensure our schema is up-to-date and migrated. - - return context; } } - public RealmContextFactory(Storage storage) + public RealmContextFactory(Storage storage, string filename) { this.storage = storage; + + Filename = filename; + + const string realm_extension = ".realm"; + + if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) + Filename += realm_extension; } - public RealmUsage GetForRead() + /// + /// Compact this realm. + /// + /// + public bool Compact() => Realm.Compact(getConfiguration()); + + protected override void Update() { - reads.Value++; - return new RealmUsage(createContext()); + base.Update(); + + lock (contextLock) + { + if (context?.Refresh() == true) + refreshes.Value++; + } } - public RealmWriteUsage GetForWrite() + public Realm CreateContext() { - writes.Value++; - pending_writes.Value++; + if (IsDisposed) + throw new ObjectDisposedException(nameof(RealmContextFactory)); - Monitor.Enter(writeLock); - return new RealmWriteUsage(createContext(), writeComplete); + try + { + contextCreationLock.Wait(); + + contexts_created.Value++; + + return Realm.GetInstance(getConfiguration()); + } + finally + { + contextCreationLock.Release(); + } + } + + private RealmConfiguration getConfiguration() + { + return new RealmConfiguration(storage.GetFullPath(Filename, true)) + { + SchemaVersion = schema_version, + MigrationCallback = onMigration, + }; + } + + private void onMigration(Migration migration, ulong lastSchemaVersion) + { } /// @@ -89,156 +135,62 @@ namespace osu.Game.Database if (IsDisposed) throw new ObjectDisposedException(nameof(RealmContextFactory)); + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException($"{nameof(BlockAllOperations)} must be called from the update thread."); + 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(); - - if (context?.Refresh() == true) - refreshes.Value++; - } - - private Realm createContext() - { try { - if (IsDisposed) - throw new ObjectDisposedException(nameof(RealmContextFactory)); + contextCreationLock.Wait(); - blockingLock.Wait(); - - contexts_created.Value++; - - return Realm.GetInstance(new RealmConfiguration(storage.GetFullPath($"{database_name}.realm", true)) + lock (contextLock) { - SchemaVersion = schema_version, - MigrationCallback = onMigration, - }); + context?.Dispose(); + context = null; + } + + const int sleep_length = 200; + int timeout = 5000; + + // see https://github.com/realm/realm-dotnet/discussions/2657 + while (!Compact()) + { + Thread.Sleep(sleep_length); + timeout -= sleep_length; + + if (timeout < 0) + throw new TimeoutException("Took too long to acquire lock"); + } } - finally + catch { - blockingLock.Release(); + contextCreationLock.Release(); + throw; } - } - private void writeComplete() - { - Monitor.Exit(writeLock); - pending_writes.Value--; - } - - private void onMigration(Migration migration, ulong lastSchemaVersion) - { - switch (lastSchemaVersion) + return new InvokeOnDisposal(this, factory => { - 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); - - var 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); + factory.contextCreationLock.Release(); + Logger.Log(@"Restoring realm operations.", LoggingTarget.Database); + }); } protected override void Dispose(bool isDisposing) { + lock (contextLock) + { + context?.Dispose(); + } + if (!IsDisposed) { - // intentionally block all operations indefinitely. this ensures that nothing can start consuming a new context after disposal. - BlockAllOperations(); - blockingLock?.Dispose(); + // intentionally block context creation indefinitely. this ensures that nothing can start consuming a new context after disposal. + contextCreationLock.Wait(); + contextCreationLock.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 index aee36e81c5..e6f3dba39f 100644 --- a/osu.Game/Database/RealmExtensions.cs +++ b/osu.Game/Database/RealmExtensions.cs @@ -1,51 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using AutoMapper; -using osu.Game.Input.Bindings; +using System; using Realms; namespace osu.Game.Database { public static class RealmExtensions { - private static readonly IMapper mapper = new MapperConfiguration(c => + public static void Write(this Realm realm, Action function) { - 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; + using var transaction = realm.BeginWrite(); + function(realm); + transaction.Commit(); } - /// - /// 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 + public static T Write(this Realm realm, Func function) { - if (!item.IsManaged) - return item; - - return mapper.Map(item); + using var transaction = realm.BeginWrite(); + var result = function(realm); + transaction.Commit(); + return result; } } } diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs new file mode 100644 index 0000000000..abb69644d6 --- /dev/null +++ b/osu.Game/Database/RealmLive.cs @@ -0,0 +1,111 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using Realms; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// Provides a method of working with realm objects over longer application lifetimes. + /// + /// The underlying object type. + public class RealmLive : ILive where T : RealmObject, IHasGuidPrimaryKey + { + public Guid ID { get; } + + private readonly SynchronizationContext? fetchedContext; + private readonly int fetchedThreadId; + + /// + /// The original live data used to create this instance. + /// + private readonly T data; + + /// + /// Construct a new instance of live realm data. + /// + /// The realm data. + public RealmLive(T data) + { + this.data = data; + + fetchedContext = SynchronizationContext.Current; + fetchedThreadId = Thread.CurrentThread.ManagedThreadId; + + ID = data.ID; + } + + /// + /// Perform a read operation on this live object. + /// + /// The action to perform. + public void PerformRead(Action perform) + { + if (originalDataValid) + { + perform(data); + return; + } + + using (var realm = Realm.GetInstance(data.Realm.Config)) + perform(realm.Find(ID)); + } + + /// + /// Perform a read operation on this live object. + /// + /// The action to perform. + public TReturn PerformRead(Func perform) + { + if (typeof(RealmObjectBase).IsAssignableFrom(typeof(TReturn))) + throw new InvalidOperationException($"Realm live objects should not exit the scope of {nameof(PerformRead)}."); + + if (originalDataValid) + return perform(data); + + using (var realm = Realm.GetInstance(data.Realm.Config)) + return perform(realm.Find(ID)); + } + + /// + /// Perform a write operation on this live object. + /// + /// The action to perform. + public void PerformWrite(Action perform) => + PerformRead(t => + { + var transaction = t.Realm.BeginWrite(); + perform(t); + transaction.Commit(); + }); + + public T Value + { + get + { + if (originalDataValid) + return data; + + T retrieved; + + using (var realm = Realm.GetInstance(data.Realm.Config)) + retrieved = realm.Find(ID); + + if (!retrieved.IsValid) + throw new InvalidOperationException("Attempted to access value without an open context"); + + return retrieved; + } + } + + private bool originalDataValid => isCorrectThread && data.IsValid; + + // this matches realm's internal thread validation (see https://github.com/realm/realm-dotnet/blob/903b4d0b304f887e37e2d905384fb572a6496e70/Realm/Realm/Native/SynchronizationContextScheduler.cs#L72) + private bool isCorrectThread + => (fetchedContext != null && SynchronizationContext.Current == fetchedContext) || fetchedThreadId == Thread.CurrentThread.ManagedThreadId; + } +} diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs new file mode 100644 index 0000000000..18a926fa8c --- /dev/null +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using AutoMapper; +using osu.Game.Input.Bindings; +using Realms; + +namespace osu.Game.Database +{ + public static class RealmObjectExtensions + { + 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); + } + + public static List> ToLive(this IEnumerable realmList) + where T : RealmObject, IHasGuidPrimaryKey + { + return realmList.Select(l => new RealmLive(l)).ToList(); + } + + public static RealmLive ToLive(this T realmObject) + where T : RealmObject, IHasGuidPrimaryKey + { + return new RealmLive(realmObject); + } + } +} diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index 19cc211709..ff81637efb 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); @@ -91,7 +115,7 @@ namespace osu.Game.Database createNewTask(); } - List foundUsers = request.Result?.Users; + List foundUsers = request.Response?.Users; if (foundUsers != null) { diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index 52d8230fb6..03cc345947 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -38,6 +38,33 @@ namespace osu.Game.Extensions return repeatDelegate; } + /// + /// Shakes this drawable. + /// + /// The target to shake. + /// The length of a single shake. + /// Pixels of displacement per shake. + /// The maximum length the shake should last. + public static void Shake(this Drawable target, double shakeDuration = 80, float shakeMagnitude = 8, double? maximumLength = null) + { + // if we don't have enough time, don't bother shaking. + if (maximumLength < shakeDuration * 2) + return; + + var sequence = target.MoveToX(shakeMagnitude, shakeDuration / 2, Easing.OutSine).Then() + .MoveToX(-shakeMagnitude, shakeDuration, Easing.InOutSine).Then(); + + // if we don't have enough time for the second shake, skip it. + if (!maximumLength.HasValue || maximumLength >= shakeDuration * 4) + { + sequence = sequence + .MoveToX(shakeMagnitude, shakeDuration, Easing.InOutSine).Then() + .MoveToX(-shakeMagnitude, shakeDuration, Easing.InOutSine).Then(); + } + + sequence.MoveToX(0, shakeDuration / 2, Easing.InSine); + } + /// /// Accepts a delta vector in screen-space coordinates and converts it to one which can be applied to this drawable's position. /// diff --git a/osu.Game/Extensions/EditorDisplayExtensions.cs b/osu.Game/Extensions/EditorDisplayExtensions.cs deleted file mode 100644 index f749b88b46..0000000000 --- a/osu.Game/Extensions/EditorDisplayExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; - -namespace osu.Game.Extensions -{ - public static class EditorDisplayExtensions - { - /// - /// Get an editor formatted string (mm:ss:mss) - /// - /// A time value in milliseconds. - /// An editor formatted display string. - public static string ToEditorFormattedString(this double milliseconds) => - ToEditorFormattedString(TimeSpan.FromMilliseconds(milliseconds)); - - /// - /// Get an editor formatted string (mm:ss:mss) - /// - /// A time value. - /// An editor formatted display string. - public static string ToEditorFormattedString(this TimeSpan timeSpan) => - $"{(timeSpan < TimeSpan.Zero ? "-" : string.Empty)}{timeSpan:mm\\:ss\\:fff}"; - } -} diff --git a/osu.Game/Extensions/TimeDisplayExtensions.cs b/osu.Game/Extensions/TimeDisplayExtensions.cs new file mode 100644 index 0000000000..dc05482a05 --- /dev/null +++ b/osu.Game/Extensions/TimeDisplayExtensions.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Localisation; + +namespace osu.Game.Extensions +{ + public static class TimeDisplayExtensions + { + /// + /// Get an editor formatted string (mm:ss:mss) + /// + /// A time value in milliseconds. + /// An editor formatted display string. + public static string ToEditorFormattedString(this double milliseconds) => + ToEditorFormattedString(TimeSpan.FromMilliseconds(milliseconds)); + + /// + /// Get an editor formatted string (mm:ss:mss) + /// + /// A time value. + /// An editor formatted display string. + public static string ToEditorFormattedString(this TimeSpan timeSpan) => + $"{(timeSpan < TimeSpan.Zero ? "-" : string.Empty)}{(int)timeSpan.TotalMinutes:00}:{timeSpan:ss\\:fff}"; + + /// + /// Get a formatted duration (dd:hh:mm:ss with days/hours omitted if zero). + /// + /// A duration in milliseconds. + /// A formatted duration string. + public static LocalisableString ToFormattedDuration(this double milliseconds) => + ToFormattedDuration(TimeSpan.FromMilliseconds(milliseconds)); + + /// + /// Get a formatted duration (dd:hh:mm:ss with days/hours omitted if zero). + /// + /// A duration value. + /// A formatted duration string. + public static LocalisableString ToFormattedDuration(this TimeSpan timeSpan) + { + if (timeSpan.TotalDays >= 1) + return new LocalisableFormattableString(timeSpan, @"dd\:hh\:mm\:ss"); + + if (timeSpan.TotalHours >= 1) + return new LocalisableFormattableString(timeSpan, @"hh\:mm\:ss"); + + return new LocalisableFormattableString(timeSpan, @"mm\:ss"); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 269360c492..ab8763e576 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Allocation; using System.Collections.Generic; using osu.Framework.Graphics.Batches; +using osu.Framework.Graphics.OpenGL.Buffers; using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Lists; @@ -153,7 +154,7 @@ namespace osu.Game.Graphics.Backgrounds TriangleParticle newParticle = parts[i]; // Scale moved distance by the size of the triangle. Smaller triangles should move more slowly. - newParticle.Position.Y += parts[i].Scale * movedDistance; + newParticle.Position.Y += Math.Max(0.5f, parts[i].Scale) * movedDistance; newParticle.Colour.A = adjustedAlpha; parts[i] = newParticle; @@ -181,7 +182,10 @@ namespace osu.Game.Graphics.Backgrounds private void addTriangles(bool randomY) { - AimCount = (int)(DrawWidth * DrawHeight * 0.002f / (triangleScale * triangleScale) * SpawnRatio); + // limited by the maximum size of QuadVertexBuffer for safety. + const int max_triangles = QuadVertexBuffer.MAX_QUADS; + + AimCount = (int)Math.Min(max_triangles, (DrawWidth * DrawHeight * 0.002f / (triangleScale * triangleScale) * SpawnRatio)); for (int i = 0; i < AimCount - parts.Count; i++) parts.Add(createTriangle(randomY)); 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/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index 5ff2fdf6b2..21c1d70d45 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Framework.Platform; using osu.Game.Graphics.Sprites; using osu.Game.Users; @@ -25,6 +26,9 @@ namespace osu.Game.Graphics.Containers [Resolved(CanBeNull = true)] private OsuGame game { get; set; } + [Resolved] + private GameHost host { get; set; } + public void AddLinks(string text, List links) { if (string.IsNullOrEmpty(text) || links == null) @@ -83,20 +87,25 @@ namespace osu.Game.Graphics.Containers private void createLink(IEnumerable drawables, LinkDetails link, string tooltipText, Action action = null) { - AddInternal(new DrawableLinkCompiler(drawables.OfType().ToList()) + var linkCompiler = CreateLinkCompiler(drawables.OfType()); + linkCompiler.RelativeSizeAxes = Axes.Both; + linkCompiler.TooltipText = tooltipText; + linkCompiler.Action = () => { - RelativeSizeAxes = Axes.Both, - TooltipText = tooltipText, - Action = () => - { - if (action != null) - action(); - else - game?.HandleLink(link); - }, - }); + if (action != null) + action(); + else if (game != null) + game.HandleLink(link); + // fallback to handle cases where OsuGame is not available, ie. tournament client. + else if (link.Action == LinkAction.External) + host.OpenUrlExternally(link.Argument); + }; + + AddInternal(linkCompiler); } + protected virtual DrawableLinkCompiler CreateLinkCompiler(IEnumerable parts) => new DrawableLinkCompiler(parts); + // We want the compilers to always be visible no matter where they are, so RelativeSizeAxes is used. // However due to https://github.com/ppy/osu-framework/issues/2073, it's possible for the compilers to be relative size in the flow's auto-size axes - an unsupported operation. // Since the compilers don't display any content and don't affect the layout, it's simplest to exclude them from the flow. diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs index 81f30bd406..296c600771 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs @@ -48,7 +48,7 @@ namespace osu.Game.Graphics.Containers.Markdown public override SpriteText CreateSpriteText() => new OsuSpriteText { - Font = OsuFont.GetFont(size: 14), + Font = OsuFont.GetFont(Typeface.Inter, size: 14, weight: FontWeight.Regular), }; public override MarkdownTextFlowContainer CreateTextFlow() => new OsuMarkdownTextFlowContainer(); diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs index a3a86df678..e4685a2935 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs @@ -70,7 +70,7 @@ namespace osu.Game.Graphics.Containers.Markdown public FontWeight FontWeight; protected override SpriteText CreateSpriteText() - => base.CreateSpriteText().With(t => t.Font = t.Font.With(size: FontSize, weight: FontWeight)); + => base.CreateSpriteText().With(t => t.Font = t.Font.With(Typeface.Torus, size: FontSize, weight: FontWeight)); } } } 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 36b48b7769..a7cd6b3905 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs @@ -17,6 +17,9 @@ 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 diff --git a/osu.Game/Graphics/Containers/OsuClickableContainer.cs b/osu.Game/Graphics/Containers/OsuClickableContainer.cs index 0bc3c876e1..bf397e4251 100644 --- a/osu.Game/Graphics/Containers/OsuClickableContainer.cs +++ b/osu.Game/Graphics/Containers/OsuClickableContainer.cs @@ -18,7 +18,7 @@ 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.Default) { @@ -39,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 b9b098df80..16ec7ab838 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -88,9 +88,9 @@ namespace osu.Game.Graphics.Containers base.OnMouseUp(e); } - public virtual bool OnPressed(GlobalAction action) + public virtual bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Back: Hide(); @@ -103,7 +103,7 @@ namespace osu.Game.Graphics.Containers return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs index 911d47704a..d43c3a608b 100644 --- a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -59,33 +60,37 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader] private void load() { - InternalChild = new GridContainer + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] + new GridContainer { - new[] + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { - handleContainer = new Container + new[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5 }, - Child = handle = new PlaylistItemHandle + handleContainer = new Container { - Size = new Vector2(12), - Colour = HandleColour, - AlwaysPresent = true, - Alpha = 0 - } - }, - CreateContent() - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 5 }, + Child = handle = new PlaylistItemHandle + { + Size = new Vector2(12), + Colour = HandleColour, + AlwaysPresent = true, + Alpha = 0 + } + }, + CreateContent() + } + }, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } }, - ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } + new HoverClickSounds() }; } diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 8ab146efe7..76492cab55 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -22,7 +22,8 @@ namespace osu.Game.Graphics.Containers where T : Drawable { public Bindable SelectedSection { get; } = new Bindable(); - private Drawable lastClickedSection; + + private T lastClickedSection; public Drawable ExpandableHeader { @@ -144,10 +145,25 @@ namespace osu.Game.Graphics.Containers footerHeight = null; } - public void ScrollTo(Drawable section) + public void ScrollTo(Drawable target) { - lastClickedSection = section; - scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - scrollContainer.DisplayableContent * scroll_y_centre - (FixedHeader?.BoundingBox.Height ?? 0)); + lastKnownScroll = null; + + float fixedHeaderSize = FixedHeader?.BoundingBox.Height ?? 0; + + // implementation similar to ScrollIntoView but a bit more nuanced. + float top = scrollContainer.GetChildPosInContent(target); + + float bottomScrollExtent = scrollContainer.ScrollableExtent - fixedHeaderSize; + float scrollTarget = top - fixedHeaderSize - scrollContainer.DisplayableContent * scroll_y_centre; + + if (scrollTarget > bottomScrollExtent) + scrollContainer.ScrollToEnd(); + else + scrollContainer.ScrollTo(scrollTarget); + + if (target is T section) + lastClickedSection = section; } public void ScrollToTop() => scrollContainer.ScrollTo(0); @@ -170,13 +186,22 @@ namespace osu.Game.Graphics.Containers if (source == InvalidationSource.Child && (invalidation & Invalidation.DrawSize) != 0) { - lastKnownScroll = null; + InvalidateScrollPosition(); result = true; } return result; } + protected void InvalidateScrollPosition() + { + Schedule(() => + { + lastKnownScroll = null; + lastClickedSection = null; + }); + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -224,15 +249,19 @@ namespace osu.Game.Graphics.Containers float scrollCentre = fixedHeaderSize + scrollContainer.DisplayableContent * scroll_y_centre + selectionLenienceAboveSection; - if (Precision.AlmostBigger(0, scrollContainer.Current)) - SelectedSection.Value = lastClickedSection as T ?? Children.FirstOrDefault(); + var presentChildren = Children.Where(c => c.IsPresent); + + if (lastClickedSection != null) + SelectedSection.Value = lastClickedSection; + else if (Precision.AlmostBigger(0, scrollContainer.Current)) + SelectedSection.Value = presentChildren.FirstOrDefault(); else if (Precision.AlmostBigger(scrollContainer.Current, scrollContainer.ScrollableExtent)) - SelectedSection.Value = lastClickedSection as T ?? Children.LastOrDefault(); + SelectedSection.Value = presentChildren.LastOrDefault(); else { - SelectedSection.Value = Children + SelectedSection.Value = presentChildren .TakeWhile(section => scrollContainer.GetChildPosInContent(section) - currentScroll - scrollCentre <= 0) - .LastOrDefault() ?? Children.FirstOrDefault(); + .LastOrDefault() ?? presentChildren.FirstOrDefault(); } } } 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/Containers/ShakeContainer.cs b/osu.Game/Graphics/Containers/ShakeContainer.cs index dca9df1e98..8a0ce287db 100644 --- a/osu.Game/Graphics/Containers/ShakeContainer.cs +++ b/osu.Game/Graphics/Containers/ShakeContainer.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 osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Extensions; namespace osu.Game.Graphics.Containers { @@ -16,40 +16,10 @@ namespace osu.Game.Graphics.Containers /// public float ShakeDuration = 80; - /// - /// Total number of shakes. May be shortened if possible. - /// - public float TotalShakes = 4; - - /// - /// Pixels of displacement per shake. - /// - public float ShakeMagnitude = 8; - /// /// Shake the contents of this container. /// /// The maximum length the shake should last. - public void Shake(double? maximumLength = null) - { - const float shake_amount = 8; - - // if we don't have enough time, don't bother shaking. - if (maximumLength < ShakeDuration * 2) - return; - - var sequence = this.MoveToX(shake_amount, ShakeDuration / 2, Easing.OutSine).Then() - .MoveToX(-shake_amount, ShakeDuration, Easing.InOutSine).Then(); - - // if we don't have enough time for the second shake, skip it. - if (!maximumLength.HasValue || maximumLength >= ShakeDuration * 4) - { - sequence = sequence - .MoveToX(shake_amount, ShakeDuration, Easing.InOutSine).Then() - .MoveToX(-shake_amount, ShakeDuration, Easing.InOutSine).Then(); - } - - sequence.MoveToX(0, ShakeDuration / 2, Easing.InSine); - } + public void Shake(double? maximumLength = null) => this.Shake(ShakeDuration, maximumLength: maximumLength); } } diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs index 17506ce0f5..0561051e35 100644 --- a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs +++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs @@ -42,6 +42,12 @@ namespace osu.Game.Graphics.Containers base.OnUserScroll(value, animated, distanceDecay); } + public new void ScrollIntoView(Drawable target, bool animated = true) + { + UserScrolling = false; + base.ScrollIntoView(target, animated); + } + public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) { UserScrolling = false; diff --git a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs index fbb3fa0e6c..171ad4ee65 100644 --- a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.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.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; @@ -9,6 +10,14 @@ namespace osu.Game.Graphics.Cursor { public class OsuContextMenuContainer : ContextMenuContainer { - protected override Menu CreateMenu() => new OsuContextMenu(); + [Cached] + private OsuContextMenuSamples samples = new OsuContextMenuSamples(); + + public OsuContextMenuContainer() + { + AddInternal(samples); + } + + protected override Menu CreateMenu() => new OsuContextMenu(true); } } diff --git a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs index 81dca99ddd..35d7b4e795 100644 --- a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs @@ -31,12 +31,9 @@ namespace osu.Game.Graphics.Cursor private readonly OsuSpriteText text; private bool instantMovement = true; - public override bool SetContent(object content) + public override void SetContent(LocalisableString contentString) { - if (!(content is LocalisableString contentString)) - return false; - - if (contentString == text.Text) return true; + if (contentString == text.Text) return; text.Text = contentString; @@ -47,8 +44,6 @@ namespace osu.Game.Graphics.Cursor } else AutoSizeDuration = 0; - - return true; } public OsuTooltip() diff --git a/osu.Game/Graphics/DateTooltip.cs b/osu.Game/Graphics/DateTooltip.cs index 67fcab43f7..3094f9cc2b 100644 --- a/osu.Game/Graphics/DateTooltip.cs +++ b/osu.Game/Graphics/DateTooltip.cs @@ -12,7 +12,7 @@ using osuTK; namespace osu.Game.Graphics { - public class DateTooltip : VisibilityContainer, ITooltip + public class DateTooltip : VisibilityContainer, ITooltip { private readonly OsuSpriteText dateText, timeText; private readonly Box background; @@ -63,14 +63,10 @@ namespace osu.Game.Graphics protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); - public bool SetContent(object content) + public void SetContent(DateTimeOffset date) { - if (!(content is DateTimeOffset date)) - return false; - dateText.Text = $"{date:d MMMM yyyy} "; timeText.Text = $"{date:HH:mm:ss \"UTC\"z}"; - return true; } public void Move(Vector2 pos) => Position = pos; diff --git a/osu.Game/Graphics/DrawableDate.cs b/osu.Game/Graphics/DrawableDate.cs index 259d9c8d6e..567a39b4f4 100644 --- a/osu.Game/Graphics/DrawableDate.cs +++ b/osu.Game/Graphics/DrawableDate.cs @@ -10,7 +10,7 @@ using osu.Game.Utils; namespace osu.Game.Graphics { - public class DrawableDate : OsuSpriteText, IHasCustomTooltip + public class DrawableDate : OsuSpriteText, IHasCustomTooltip { private DateTimeOffset date; @@ -75,8 +75,8 @@ namespace osu.Game.Graphics private void updateTime() => Text = Format(); - public ITooltip GetCustomTooltip() => new DateTooltip(); + public ITooltip GetCustomTooltip() => new DateTooltip(); - public object TooltipContent => Date; + public DateTimeOffset TooltipContent => Date; } } diff --git a/osu.Game/Overlays/AccountCreation/ErrorTextFlowContainer.cs b/osu.Game/Graphics/ErrorTextFlowContainer.cs similarity index 95% rename from osu.Game/Overlays/AccountCreation/ErrorTextFlowContainer.cs rename to osu.Game/Graphics/ErrorTextFlowContainer.cs index 87ff4dd398..f17a2a2c3d 100644 --- a/osu.Game/Overlays/AccountCreation/ErrorTextFlowContainer.cs +++ b/osu.Game/Graphics/ErrorTextFlowContainer.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics; using osu.Game.Graphics.Containers; using osuTK.Graphics; -namespace osu.Game.Overlays.AccountCreation +namespace osu.Game.Graphics { public class ErrorTextFlowContainer : OsuTextFlowContainer { diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index a44c28eaa6..af2bb26871 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Utils; using osuTK.Graphics; namespace osu.Game.Graphics @@ -14,31 +17,52 @@ namespace osu.Game.Graphics public static Color4 Gray(float amt) => new Color4(amt, amt, amt, 1f); public static Color4 Gray(byte amt) => new Color4(amt, amt, amt, 255); + /// + /// Retrieves the colour for a . + /// + /// + /// Sourced from the @diff-{rating} variables in https://github.com/ppy/osu-web/blob/71fbab8936d79a7929d13854f5e854b4f383b236/resources/assets/less/variables.less. + /// public Color4 ForDifficultyRating(DifficultyRating difficulty, bool useLighterColour = false) { switch (difficulty) { case DifficultyRating.Easy: - return Green; + return Color4Extensions.FromHex("4ebfff"); - default: case DifficultyRating.Normal: - return Blue; + return Color4Extensions.FromHex("66ff91"); case DifficultyRating.Hard: - return Yellow; + return Color4Extensions.FromHex("f7e85d"); case DifficultyRating.Insane: - return Pink; + return Color4Extensions.FromHex("ff7e68"); case DifficultyRating.Expert: - return PurpleLight; + return Color4Extensions.FromHex("fe3c71"); case DifficultyRating.ExpertPlus: - return useLighterColour ? Gray9 : Color4Extensions.FromHex("#121415"); + return Color4Extensions.FromHex("6662dd"); + + default: + throw new ArgumentOutOfRangeException(nameof(difficulty)); } } + public Color4 ForStarDifficulty(double starDifficulty) => ColourUtils.SampleFromLinearGradient(new[] + { + (1.5f, Color4Extensions.FromHex("4fc0ff")), + (2.0f, Color4Extensions.FromHex("4fffd5")), + (2.5f, Color4Extensions.FromHex("7cff4f")), + (3.25f, Color4Extensions.FromHex("f6f05c")), + (4.5f, Color4Extensions.FromHex("ff8068")), + (6.0f, Color4Extensions.FromHex("ff3c71")), + (7.0f, Color4Extensions.FromHex("6563de")), + (8.0f, Color4Extensions.FromHex("18158e")), + (8.0f, Color4.Black), + }, (float)Math.Round(starDifficulty, 2, MidpointRounding.AwayFromZero)); + /// /// Retrieves the colour for a . /// @@ -106,6 +130,9 @@ namespace osu.Game.Graphics return Gray(brightness > 0.5f ? 0.2f : 0.9f); } + public readonly Color4 TeamColourRed = Color4Extensions.FromHex("#AA1414"); + public readonly Color4 TeamColourBlue = Color4Extensions.FromHex("#1462AA"); + // See https://github.com/ppy/osu-web/blob/master/resources/assets/less/colors.less public readonly Color4 PurpleLighter = Color4Extensions.FromHex(@"eeeeff"); public readonly Color4 PurpleLight = Color4Extensions.FromHex(@"aa88ff"); @@ -198,8 +225,24 @@ 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 Pink3 = Color4Extensions.FromHex(@"cc3378"); + + /// + /// Equivalent to 's . + /// + public readonly Color4 Blue3 = Color4Extensions.FromHex(@"3399cc"); + + /// + /// 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/OsuFont.cs b/osu.Game/Graphics/OsuFont.cs index 7c78141b4d..edb484021c 100644 --- a/osu.Game/Graphics/OsuFont.cs +++ b/osu.Game/Graphics/OsuFont.cs @@ -21,6 +21,10 @@ namespace osu.Game.Graphics public static FontUsage Torus => GetFont(Typeface.Torus, weight: FontWeight.Regular); + public static FontUsage TorusAlternate => GetFont(Typeface.TorusAlternate, weight: FontWeight.Regular); + + public static FontUsage Inter => GetFont(Typeface.Inter, weight: FontWeight.Regular); + /// /// Retrieves a . /// @@ -54,6 +58,12 @@ namespace osu.Game.Graphics case Typeface.Torus: return "Torus"; + + case Typeface.TorusAlternate: + return "Torus-Alternate"; + + case Typeface.Inter: + return "Inter"; } return null; @@ -107,7 +117,9 @@ namespace osu.Game.Graphics public enum Typeface { Venera, - Torus + Torus, + TorusAlternate, + Inter, } public enum FontWeight diff --git a/osu.Game/Graphics/ParticleExplosion.cs b/osu.Game/Graphics/ParticleExplosion.cs index e0d2b50c55..094cc87bbe 100644 --- a/osu.Game/Graphics/ParticleExplosion.cs +++ b/osu.Game/Graphics/ParticleExplosion.cs @@ -115,6 +115,8 @@ namespace osu.Game.Graphics null, TextureCoords); } } + + protected override bool CanDrawOpaqueInterior => false; } private readonly struct ParticlePart diff --git a/osu.Game/Graphics/ParticleSpewer.cs b/osu.Game/Graphics/ParticleSpewer.cs new file mode 100644 index 0000000000..54a2b1e890 --- /dev/null +++ b/osu.Game/Graphics/ParticleSpewer.cs @@ -0,0 +1,200 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; + +namespace osu.Game.Graphics +{ + public abstract class ParticleSpewer : Sprite + { + private readonly FallingParticle[] particles; + private int currentIndex; + private double lastParticleAdded; + + private readonly double cooldown; + private readonly double maxDuration; + + /// + /// Determines whether particles are being spawned. + /// + public readonly BindableBool Active = new BindableBool(); + + public override bool IsPresent => base.IsPresent && hasActiveParticles; + + protected virtual bool CanSpawnParticles => true; + protected virtual float ParticleGravity => 0; + + private bool hasActiveParticles => Active.Value || (lastParticleAdded + maxDuration) > Time.Current; + + protected ParticleSpewer(Texture texture, int perSecond, double maxDuration) + { + Texture = texture; + Blending = BlendingParameters.Additive; + + particles = new FallingParticle[perSecond * (int)Math.Ceiling(maxDuration / 1000)]; + + cooldown = 1000f / perSecond; + this.maxDuration = maxDuration; + } + + protected override void Update() + { + base.Update(); + + if (Active.Value && CanSpawnParticles && Math.Abs(Time.Current - lastParticleAdded) > cooldown) + { + var newParticle = CreateParticle(); + newParticle.StartTime = (float)Time.Current; + + particles[currentIndex] = newParticle; + + currentIndex = (currentIndex + 1) % particles.Length; + lastParticleAdded = Time.Current; + } + + Invalidate(Invalidation.DrawNode); + } + + /// + /// Called each time a new particle should be spawned. + /// + protected virtual FallingParticle CreateParticle() => new FallingParticle(); + + protected override DrawNode CreateDrawNode() => new ParticleSpewerDrawNode(this); + + # region DrawNode + + private class ParticleSpewerDrawNode : SpriteDrawNode + { + private readonly FallingParticle[] particles; + + protected new ParticleSpewer Source => (ParticleSpewer)base.Source; + + private readonly float maxDuration; + + private float currentTime; + private float gravity; + private Axes relativePositionAxes; + private Vector2 sourceSize; + + public ParticleSpewerDrawNode(ParticleSpewer source) + : base(source) + { + particles = new FallingParticle[Source.particles.Length]; + maxDuration = (float)Source.maxDuration; + } + + public override void ApplyState() + { + base.ApplyState(); + + Source.particles.CopyTo(particles, 0); + + currentTime = (float)Source.Time.Current; + gravity = Source.ParticleGravity; + relativePositionAxes = Source.RelativePositionAxes; + sourceSize = Source.DrawSize; + } + + protected override void Blit(Action vertexAction) + { + foreach (var p in particles) + { + var timeSinceStart = currentTime - p.StartTime; + + // ignore particles from the future. + // these can appear when seeking in replays. + if (timeSinceStart < 0) continue; + + var alpha = p.AlphaAtTime(timeSinceStart); + if (alpha <= 0) continue; + + var pos = p.PositionAtTime(timeSinceStart, gravity, maxDuration); + var scale = p.ScaleAtTime(timeSinceStart); + var angle = p.AngleAtTime(timeSinceStart); + + var rect = createDrawRect(pos, scale); + + var quad = new Quad( + transformPosition(rect.TopLeft, rect.Centre, angle), + transformPosition(rect.TopRight, rect.Centre, angle), + transformPosition(rect.BottomLeft, rect.Centre, angle), + transformPosition(rect.BottomRight, rect.Centre, angle) + ); + + DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction, + new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + null, TextureCoords); + } + } + + private RectangleF createDrawRect(Vector2 position, float scale) + { + var width = Texture.DisplayWidth * scale; + var height = Texture.DisplayHeight * scale; + + if (relativePositionAxes.HasFlagFast(Axes.X)) + position.X *= sourceSize.X; + if (relativePositionAxes.HasFlagFast(Axes.Y)) + position.Y *= sourceSize.Y; + + return new RectangleF( + position.X - width / 2, + position.Y - height / 2, + width, + height); + } + + private Vector2 transformPosition(Vector2 pos, Vector2 centre, float angle) + { + float cos = MathF.Cos(angle); + float sin = MathF.Sin(angle); + + float x = centre.X + (pos.X - centre.X) * cos + (pos.Y - centre.Y) * sin; + float y = centre.Y + (pos.Y - centre.Y) * cos - (pos.X - centre.X) * sin; + + return Vector2Extensions.Transform(new Vector2(x, y), DrawInfo.Matrix); + } + + protected override bool CanDrawOpaqueInterior => false; + } + + #endregion + + protected struct FallingParticle + { + public float StartTime; + public Vector2 StartPosition; + public Vector2 Velocity; + public float Duration; + public float StartAngle; + public float EndAngle; + public float EndScale; + + public float AlphaAtTime(float timeSinceStart) => 1 - progressAtTime(timeSinceStart); + + public float ScaleAtTime(float timeSinceStart) => (float)Interpolation.Lerp(1, EndScale, progressAtTime(timeSinceStart)); + + public float AngleAtTime(float timeSinceStart) => (float)Interpolation.Lerp(StartAngle, EndAngle, progressAtTime(timeSinceStart)); + + public Vector2 PositionAtTime(float timeSinceStart, float gravity, float maxDuration) + { + var progress = progressAtTime(timeSinceStart); + var currentGravity = new Vector2(0, gravity * Duration / maxDuration * progress); + + return StartPosition + (Velocity + currentGravity) * timeSinceStart / maxDuration; + } + + private float progressAtTime(float timeSinceStart) => Math.Clamp(timeSinceStart / Duration, 0, 1); + } + } +} diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index fb7fe4947b..9cd403f409 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -12,6 +12,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Configuration; @@ -57,9 +58,9 @@ namespace osu.Game.Graphics shutter = audio.Samples.Get("UI/shutter"); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.TakeScreenshot: shutter.Play(); @@ -70,7 +71,7 @@ namespace osu.Game.Graphics return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Graphics/UserInterface/BackButton.cs b/osu.Game/Graphics/UserInterface/BackButton.cs index b941e5fcbd..c965fbcf45 100644 --- a/osu.Game/Graphics/UserInterface/BackButton.cs +++ b/osu.Game/Graphics/UserInterface/BackButton.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Input.Bindings; namespace osu.Game.Graphics.UserInterface @@ -20,7 +21,7 @@ namespace osu.Game.Graphics.UserInterface { Size = TwoLayerButton.SIZE_EXTENDED; - Child = button = new TwoLayerButton + Child = button = new TwoLayerButton(HoverSampleSet.Submit) { Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, @@ -35,7 +36,7 @@ namespace osu.Game.Graphics.UserInterface Add(receptor = new Receptor()); } - receptor.OnBackPressed = () => button.Click(); + receptor.OnBackPressed = () => button.TriggerClick(); } [BackgroundDependencyLoader] @@ -61,9 +62,9 @@ namespace osu.Game.Graphics.UserInterface { public Action OnBackPressed; - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Back: OnBackPressed?.Invoke(); @@ -73,7 +74,7 @@ namespace osu.Game.Graphics.UserInterface return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } } diff --git a/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs b/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs new file mode 100644 index 0000000000..4e1c612f09 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.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.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterface +{ + public abstract class CommaSeparatedScoreCounter : RollingCounter + { + protected override double RollingDuration => 1000; + protected override Easing RollingEasing => Easing.Out; + + protected override double GetProportionalDuration(double currentValue, double newValue) => + currentValue > newValue ? currentValue - newValue : newValue - currentValue; + + protected override LocalisableString FormatCount(double count) => ((long)count).ToLocalisableString(@"N0"); + + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); + } +} diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index 1047aa4255..2f9e4dae51 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; @@ -40,6 +56,7 @@ namespace osu.Game.Graphics.UserInterface private Vector2 hoverSpacing => new Vector2(3f, 0f); public DialogButton() + : base(HoverSampleSet.Submit) { RelativeSizeAxes = Axes.X; @@ -153,7 +170,7 @@ namespace osu.Game.Graphics.UserInterface updateGlow(); - Selected.ValueChanged += selectionChanged; + StateChanged += selectionChanged; } private Color4 buttonColour; @@ -221,7 +238,7 @@ namespace osu.Game.Graphics.UserInterface .OnComplete(_ => { clickAnimating = false; - Selected.TriggerChange(); + StateChanged?.Invoke(State); }); return base.OnClick(e); @@ -235,7 +252,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 +260,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 +268,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/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 6ad88eaaba..0df69a5b54 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -23,14 +23,20 @@ namespace osu.Game.Graphics.UserInterface [Resolved] private GameHost host { get; set; } + private readonly SpriteIcon linkIcon; + public ExternalLinkButton(string link = null) { Link = link; Size = new Vector2(12); - InternalChild = new SpriteIcon + InternalChildren = new Drawable[] { - Icon = FontAwesome.Solid.ExternalLinkAlt, - RelativeSizeAxes = Axes.Both + linkIcon = new SpriteIcon + { + Icon = FontAwesome.Solid.ExternalLinkAlt, + RelativeSizeAxes = Axes.Both + }, + new HoverClickSounds(HoverSampleSet.Submit) }; } @@ -42,13 +48,13 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { - InternalChild.FadeColour(hoverColour, 500, Easing.OutQuint); + linkIcon.FadeColour(hoverColour, 500, Easing.OutQuint); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - InternalChild.FadeColour(Color4.White, 500, Easing.OutQuint); + linkIcon.FadeColour(Color4.White, 500, Easing.OutQuint); base.OnHoverLost(e); } diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index f77a3109c9..ceea9620c8 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs @@ -22,9 +22,14 @@ namespace osu.Game.Graphics.UserInterface public void TakeFocus() { - if (allowImmediateFocus) GetContainingInputManager().ChangeFocus(this); + if (!allowImmediateFocus) + return; + + Scheduler.Add(() => GetContainingInputManager().ChangeFocus(this), false); } + public new void KillFocus() => base.KillFocus(); + public bool HoldFocus { get => allowImmediateFocus && focus; @@ -65,11 +70,11 @@ namespace osu.Game.Graphics.UserInterface return base.OnKeyDown(e); } - public bool OnPressed(GlobalAction action) + public virtual bool OnPressed(KeyBindingPressEvent e) { if (!HasFocus) return false; - if (action == GlobalAction.Back) + if (e.Action == GlobalAction.Back) { if (Text.Length > 0) { @@ -81,7 +86,7 @@ namespace osu.Game.Graphics.UserInterface return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Graphics/UserInterface/GrayButton.cs b/osu.Game/Graphics/UserInterface/GrayButton.cs index 88c46f29e0..0a2c83d5a8 100644 --- a/osu.Game/Graphics/UserInterface/GrayButton.cs +++ b/osu.Game/Graphics/UserInterface/GrayButton.cs @@ -27,7 +27,7 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load() { - Children = new Drawable[] + AddRange(new Drawable[] { Background = new Box { @@ -42,7 +42,7 @@ namespace osu.Game.Graphics.UserInterface Size = new Vector2(13), Icon = icon, }, - }; + }); } } } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs index 55f43cfe46..1fd03a34e7 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs @@ -15,11 +15,6 @@ namespace osu.Game.Graphics.UserInterface /// public abstract class HoverSampleDebounceComponent : CompositeDrawable { - /// - /// Length of debounce for hover sound playback, in milliseconds. - /// - public double HoverDebounceTime { get; } = 20; - private Bindable lastPlaybackTime; [BackgroundDependencyLoader] @@ -34,7 +29,7 @@ namespace osu.Game.Graphics.UserInterface if (e.HasAnyButtonPressed) return false; - bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= HoverDebounceTime; + bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME; if (enoughTimePassedSinceLastPlayback) { diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index b88f81a143..a5ea6fcfbf 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -10,6 +10,9 @@ namespace osu.Game.Graphics.UserInterface [Description("default")] Default, + [Description("submit")] + Submit, + [Description("button")] Button, diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index c0ef5cb3fc..7db1efc75f 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -6,7 +6,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Extensions; using osu.Framework.Graphics; -using osu.Game.Configuration; using osu.Framework.Utils; namespace osu.Game.Graphics.UserInterface @@ -28,7 +27,7 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(AudioManager audio, SessionStatics statics) + private void load(AudioManager audio) { sampleHover = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-hover") ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-hover"); diff --git a/osu.Game/Graphics/UserInterface/Nub.cs b/osu.Game/Graphics/UserInterface/Nub.cs index 18d8b880ea..6807d007bb 100644 --- a/osu.Game/Graphics/UserInterface/Nub.cs +++ b/osu.Game/Graphics/UserInterface/Nub.cs @@ -6,6 +6,7 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -21,6 +22,9 @@ namespace osu.Game.Graphics.UserInterface private const float border_width = 3; + private const double animate_in_duration = 150; + private const double animate_out_duration = 500; + public Nub() { Box fill; @@ -42,7 +46,11 @@ namespace osu.Game.Graphics.UserInterface }, }; - Current.ValueChanged += filled => fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); + Current.ValueChanged += filled => + { + fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); + this.TransformTo(nameof(BorderThickness), filled.NewValue ? 8.5f : border_width, 200, Easing.OutQuint); + }; } [BackgroundDependencyLoader] @@ -54,18 +62,13 @@ namespace osu.Game.Graphics.UserInterface EdgeEffect = new EdgeEffectParameters { - Colour = GlowColour, + Colour = GlowColour.Opacity(0), Type = EdgeEffectType.Glow, Radius = 10, Roundness = 8, }; } - protected override void LoadComplete() - { - FadeEdgeEffectTo(0); - } - private bool glowing; public bool Glowing @@ -77,20 +80,26 @@ namespace osu.Game.Graphics.UserInterface if (value) { - this.FadeColour(GlowingAccentColour, 500, Easing.OutQuint); - FadeEdgeEffectTo(1, 500, Easing.OutQuint); + this.FadeColour(GlowingAccentColour, animate_in_duration, Easing.OutQuint); + FadeEdgeEffectTo(1, animate_in_duration, Easing.OutQuint); } else { - FadeEdgeEffectTo(0, 500); - this.FadeColour(AccentColour, 500); + FadeEdgeEffectTo(0, animate_out_duration); + this.FadeColour(AccentColour, animate_out_duration); } } } public bool Expanded { - set => this.ResizeTo(new Vector2(value ? EXPANDED_SIZE : COLLAPSED_SIZE, 12), 500, Easing.OutQuint); + set + { + if (value) + this.ResizeTo(new Vector2(EXPANDED_SIZE, 12), animate_in_duration, Easing.OutQuint); + else + this.ResizeTo(new Vector2(COLLAPSED_SIZE, 12), animate_out_duration, Easing.OutQuint); + } } private readonly Bindable current = new Bindable(); @@ -144,7 +153,7 @@ namespace osu.Game.Graphics.UserInterface glowColour = value; var effect = EdgeEffect; - effect.Colour = value; + effect.Colour = Glowing ? value : value.Opacity(0); EdgeEffect = effect; } } diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index cd9ca9f87f..82a3e73b84 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -36,6 +36,7 @@ namespace osu.Game.Graphics.UserInterface public Color4 BackgroundColour { + get => backgroundColour ?? Color4.White; set { backgroundColour = value; diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs index 8c7b44f952..cf201b18b4 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs @@ -3,6 +3,7 @@ using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; @@ -14,7 +15,14 @@ namespace osu.Game.Graphics.UserInterface { private const int fade_duration = 250; - public OsuContextMenu() + [Resolved] + private OsuContextMenuSamples samples { get; set; } + + // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed. + private bool wasOpened; + private readonly bool playClickSample; + + public OsuContextMenu(bool playClickSample = false) : base(Direction.Vertical) { MaskingContainer.CornerRadius = 5; @@ -28,16 +36,38 @@ namespace osu.Game.Graphics.UserInterface ItemsContainer.Padding = new MarginPadding { Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL }; MaxHeight = 250; + + this.playClickSample = playClickSample; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, AudioManager audio) { BackgroundColour = colours.ContextMenuGray; } - protected override void AnimateOpen() => this.FadeIn(fade_duration, Easing.OutQuint); - protected override void AnimateClose() => this.FadeOut(fade_duration, Easing.OutQuint); + protected override void AnimateOpen() + { + this.FadeIn(fade_duration, Easing.OutQuint); + + if (playClickSample) + samples.PlayClickSample(); + + if (!wasOpened) + samples.PlayOpenSample(); + + wasOpened = true; + } + + protected override void AnimateClose() + { + this.FadeOut(fade_duration, Easing.OutQuint); + + if (wasOpened) + samples.PlayCloseSample(); + + wasOpened = false; + } protected override Menu CreateSubMenu() => new OsuContextMenu(); } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs b/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs new file mode 100644 index 0000000000..d67ea499e5 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions; +using osu.Framework.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + public class OsuContextMenuSamples : Component + { + private Sample sampleClick; + private Sample sampleOpen; + private Sample sampleClose; + + [BackgroundDependencyLoader] + private void load(OsuColour colours, AudioManager audio) + { + sampleClick = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); + sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); + sampleClose = audio.Samples.Get(@"UI/dropdown-close"); + } + + public void PlayClickSample() => Scheduler.AddOnce(playClickSample); + private void playClickSample() => sampleClick.Play(); + + public void PlayOpenSample() => Scheduler.AddOnce(playOpenSample); + private void playOpenSample() => sampleOpen.Play(); + + public void PlayCloseSample() => Scheduler.AddOnce(playCloseSample); + private void playCloseSample() => sampleClose.Play(); + } +} diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index b97f12df02..fe88e6f78a 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -69,6 +69,7 @@ namespace osu.Game.Graphics.UserInterface BackgroundColour = Color4.Black.Opacity(0.5f); MaskingContainer.CornerRadius = corner_radius; + Alpha = 0; // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring ItemsContainer.Padding = new MarginPadding(5); @@ -94,9 +95,11 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateClose() { - this.FadeOut(300, Easing.OutQuint); if (wasOpened) + { + this.FadeOut(300, Easing.OutQuint); sampleClose?.Play(); + } } // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring @@ -271,24 +274,43 @@ namespace osu.Game.Graphics.UserInterface CornerRadius = corner_radius; Height = 40; - Foreground.Children = new Drawable[] + Foreground.Child = new GridContainer { - Text = new OsuSpriteText + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + new Dimension(GridSizeMode.AutoSize), }, - Icon = new SpriteIcon + ColumnDimensions = new[] { - Icon = FontAwesome.Solid.ChevronDown, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding { Right = 5 }, - Size = new Vector2(12), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), }, + Content = new[] + { + new Drawable[] + { + Text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + Truncate = true, + }, + Icon = new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronDown, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Horizontal = 5 }, + Size = new Vector2(12), + }, + } + } }; - AddInternal(new HoverSounds()); + AddInternal(new HoverClickSounds()); } [BackgroundDependencyLoader] diff --git a/osu.Game/Graphics/UserInterface/OsuMenu.cs b/osu.Game/Graphics/UserInterface/OsuMenu.cs index e7bf4f66ee..a16adcbd57 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenu.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -13,6 +16,12 @@ namespace osu.Game.Graphics.UserInterface { public class OsuMenu : Menu { + private Sample sampleOpen; + private Sample sampleClose; + + // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed. + private bool wasOpened; + public OsuMenu(Direction direction, bool topLevelMenu = false) : base(direction, topLevelMenu) { @@ -22,8 +31,30 @@ namespace osu.Game.Graphics.UserInterface ItemsContainer.Padding = new MarginPadding(5); } - protected override void AnimateOpen() => this.FadeIn(300, Easing.OutQuint); - protected override void AnimateClose() => this.FadeOut(300, Easing.OutQuint); + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); + sampleClose = audio.Samples.Get(@"UI/dropdown-close"); + } + + protected override void AnimateOpen() + { + if (!TopLevelMenu && !wasOpened) + sampleOpen?.Play(); + + this.FadeIn(300, Easing.OutQuint); + wasOpened = true; + } + + protected override void AnimateClose() + { + if (!TopLevelMenu && wasOpened) + sampleClose?.Play(); + + this.FadeOut(300, Easing.OutQuint); + wasOpened = false; + } protected override void UpdateSize(Vector2 newSize) { diff --git a/osu.Game/Graphics/UserInterface/OsuTabControl.cs b/osu.Game/Graphics/UserInterface/OsuTabControl.cs index c447d7f609..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)?.GetLocalisableDescription() ?? value.ToString(), + Text = text, Font = OsuFont.GetFont(size: 14) }, Bar = new Box diff --git a/osu.Game/Graphics/UserInterface/PercentageCounter.cs b/osu.Game/Graphics/UserInterface/PercentageCounter.cs index 2d53ec066b..0ebf2849fe 100644 --- a/osu.Game/Graphics/UserInterface/PercentageCounter.cs +++ b/osu.Game/Graphics/UserInterface/PercentageCounter.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Utils; @@ -27,7 +28,7 @@ namespace osu.Game.Graphics.UserInterface Current.Value = DisplayedCount = 1.0f; } - protected override string FormatCount(double count) => count.FormatAccuracy(); + protected override LocalisableString FormatCount(double count) => count.FormatAccuracy(); protected override double GetProportionalDuration(double currentValue, double newValue) { diff --git a/osu.Game/Graphics/UserInterface/RollingCounter.cs b/osu.Game/Graphics/UserInterface/RollingCounter.cs index b96181416d..16555075d1 100644 --- a/osu.Game/Graphics/UserInterface/RollingCounter.cs +++ b/osu.Game/Graphics/UserInterface/RollingCounter.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -24,7 +25,9 @@ namespace osu.Game.Graphics.UserInterface set => current.Current = value; } - private SpriteText displayedCountSpriteText; + private IHasText displayedCountText; + + public Drawable DrawableCount { get; private set; } /// /// If true, the roll-up duration will be proportional to change in value. @@ -71,16 +74,16 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load() { - displayedCountSpriteText = CreateSpriteText(); + displayedCountText = CreateText(); UpdateDisplay(); - Child = displayedCountSpriteText; + Child = DrawableCount = (Drawable)displayedCountText; } protected void UpdateDisplay() { - if (displayedCountSpriteText != null) - displayedCountSpriteText.Text = FormatCount(DisplayedCount); + if (displayedCountText != null) + displayedCountText.Text = FormatCount(DisplayedCount); } protected override void LoadComplete() @@ -137,8 +140,8 @@ namespace osu.Game.Graphics.UserInterface /// Used to format counts. /// /// Count to format. - /// Count formatted as a string. - protected virtual string FormatCount(T count) + /// Count formatted as a localisable string. + protected virtual LocalisableString FormatCount(T count) { return count.ToString(); } @@ -159,6 +162,15 @@ namespace osu.Game.Graphics.UserInterface this.TransformTo(nameof(DisplayedCount), newValue, rollingTotalDuration, RollingEasing); } + /// + /// Creates the text. Delegates to by default. + /// + protected virtual IHasText CreateText() => CreateSpriteText(); + + /// + /// Creates an which may be used to display this counter's text. + /// May not be called if is overridden. + /// protected virtual OsuSpriteText CreateSpriteText() => new OsuSpriteText { Font = OsuFont.Numeric.With(size: 40f), diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index 5747c846eb..25f19aa0a9 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface @@ -12,43 +14,30 @@ namespace osu.Game.Graphics.UserInterface protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; - /// - /// Whether comma separators should be displayed. - /// - public bool UseCommaSeparator { get; } - public Bindable RequiredDisplayDigits { get; } = new Bindable(); + private string formatString; + /// /// Displays score. /// /// How many leading zeroes the counter will have. - /// Whether comma separators should be displayed. - protected ScoreCounter(int leading = 0, bool useCommaSeparator = false) + protected ScoreCounter(int leading = 0) { - UseCommaSeparator = useCommaSeparator; - RequiredDisplayDigits.Value = leading; - RequiredDisplayDigits.BindValueChanged(_ => UpdateDisplay()); + RequiredDisplayDigits.BindValueChanged(displayDigitsChanged, true); } - protected override double GetProportionalDuration(double currentValue, double newValue) + private void displayDigitsChanged(ValueChangedEvent _) { - return currentValue > newValue ? currentValue - newValue : newValue - currentValue; + formatString = new string('0', RequiredDisplayDigits.Value); + UpdateDisplay(); } - protected override string FormatCount(double count) - { - string format = new string('0', RequiredDisplayDigits.Value); + protected override double GetProportionalDuration(double currentValue, double newValue) => + currentValue > newValue ? currentValue - newValue : newValue - currentValue; - if (UseCommaSeparator) - { - for (int i = format.Length - 3; i > 0; i -= 3) - format = format.Insert(i, @","); - } - - return ((long)count).ToString(format); - } + protected override LocalisableString FormatCount(double count) => ((long)count).ToLocalisableString(formatString); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); diff --git a/osu.Game/Graphics/UserInterface/SearchTextBox.cs b/osu.Game/Graphics/UserInterface/SearchTextBox.cs index fe92054d25..6937782be6 100644 --- a/osu.Game/Graphics/UserInterface/SearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SearchTextBox.cs @@ -30,25 +30,20 @@ namespace osu.Game.Graphics.UserInterface PlaceholderText = "type to search"; } - public override bool OnPressed(PlatformAction action) + public override bool OnPressed(KeyBindingPressEvent e) { - switch (action.ActionType) + switch (e.Action) { - case PlatformActionType.LineEnd: - case PlatformActionType.LineStart: - return false; - + case PlatformAction.MoveBackwardLine: + case PlatformAction.MoveForwardLine: // Shift+delete is handled via PlatformAction on macOS. this is not so useful in the context of a SearchTextBox // as we do not allow arrow key navigation in the first place (ie. the caret should always be at the end of text) // Avoid handling it here to allow other components to potentially consume the shortcut. - case PlatformActionType.CharNext: - if (action.ActionMethod == PlatformActionMethod.Delete) - return false; - - break; + case PlatformAction.DeleteForwardChar: + return false; } - return base.OnPressed(action); + return base.OnPressed(e); } protected override bool OnKeyDown(KeyDownEvent e) 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/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index 8f03c7073c..969309bc79 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -71,7 +71,8 @@ namespace osu.Game.Graphics.UserInterface } } - public TwoLayerButton() + public TwoLayerButton(HoverSampleSet sampleSet = HoverSampleSet.Default) + : base(sampleSet) { Size = SIZE_RETRACTED; Shear = shear; diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs index 01d91f7cfd..5240df74a2 100644 --- a/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs +++ b/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs @@ -1,32 +1,39 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osuTK; -using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { /// /// A component which displays a colour along with related description text. /// - public class ColourDisplay : CompositeDrawable, IHasCurrentValue + public class ColourDisplay : CompositeDrawable, IHasCurrentValue { - private readonly BindableWithCurrent current = new BindableWithCurrent(); + /// + /// Invoked when the user has requested the colour corresponding to this + /// to be removed from its palette. + /// + public event Action DeleteRequested; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); - private Box fill; - private OsuSpriteText colourHexCode; private OsuSpriteText colourName; - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; @@ -62,24 +69,10 @@ namespace osu.Game.Graphics.UserInterfaceV2 Spacing = new Vector2(0, 10), Children = new Drawable[] { - new CircularContainer + new ColourCircle { - RelativeSizeAxes = Axes.X, - Height = 100, - Masking = true, - Children = new Drawable[] - { - fill = new Box - { - RelativeSizeAxes = Axes.Both - }, - colourHexCode = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.Default.With(size: 12) - } - } + Current = { BindTarget = Current }, + DeleteRequested = () => DeleteRequested?.Invoke(this) }, colourName = new OsuSpriteText { @@ -90,18 +83,64 @@ namespace osu.Game.Graphics.UserInterfaceV2 }; } - protected override void LoadComplete() + private class ColourCircle : OsuClickableContainer, IHasPopover, IHasContextMenu { - base.LoadComplete(); + public Bindable Current { get; } = new Bindable(); - current.BindValueChanged(_ => updateColour(), true); - } + public Action DeleteRequested { get; set; } - private void updateColour() - { - fill.Colour = current.Value; - colourHexCode.Text = current.Value.ToHex(); - colourHexCode.Colour = OsuColour.ForegroundTextColourFor(current.Value); + private readonly Box fill; + private readonly OsuSpriteText colourHexCode; + + public ColourCircle() + { + RelativeSizeAxes = Axes.X; + Height = 100; + CornerRadius = 50; + Masking = true; + Action = this.ShowPopover; + + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both + }, + colourHexCode = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 12) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateColour(), true); + } + + private void updateColour() + { + fill.Colour = Current.Value; + colourHexCode.Text = Current.Value.ToHex(); + colourHexCode.Colour = OsuColour.ForegroundTextColourFor(Current.Value); + } + + public Popover GetPopover() => new OsuPopover(false) + { + Child = new OsuColourPicker + { + Current = { BindTarget = Current } + } + }; + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Delete", MenuItemType.Destructive, () => DeleteRequested?.Invoke()) + }; } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs index ba950048dc..a966f61b74 100644 --- a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs @@ -1,14 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using 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.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -17,7 +22,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// public class ColourPalette : CompositeDrawable { - public BindableList Colours { get; } = new BindableList(); + public BindableList Colours { get; } = new BindableList(); private string colourNamePrefix = "Colour"; @@ -36,36 +41,24 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - private FillFlowContainer palette; - private Container placeholder; + private FillFlowContainer palette; + + private IEnumerable colourDisplays => palette.OfType(); [BackgroundDependencyLoader] private void load() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + AutoSizeDuration = fade_duration; + AutoSizeEasing = Easing.OutQuint; - InternalChildren = new Drawable[] + InternalChild = palette = new FillFlowContainer { - palette = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), - Direction = FillDirection.Full - }, - placeholder = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new OsuSpriteText - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Text = "(none)", - Font = OsuFont.Default.With(weight: FontWeight.Bold) - } - } + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Direction = FillDirection.Full }; } @@ -73,7 +66,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 { base.LoadComplete(); - Colours.BindCollectionChanged((_, __) => updatePalette(), true); + Colours.BindCollectionChanged((_, args) => + { + if (args.Action != NotifyCollectionChangedAction.Replace) + updatePalette(); + }, true); FinishTransforms(true); } @@ -83,37 +80,103 @@ namespace osu.Game.Graphics.UserInterfaceV2 { palette.Clear(); - if (Colours.Any()) + for (int i = 0; i < Colours.Count; ++i) { - palette.FadeIn(fade_duration, Easing.OutQuint); - placeholder.FadeOut(fade_duration, Easing.OutQuint); - } - else - { - palette.FadeOut(fade_duration, Easing.OutQuint); - placeholder.FadeIn(fade_duration, Easing.OutQuint); + // copy to avoid accesses to modified closure. + int colourIndex = i; + ColourDisplay display; + + palette.Add(display = new ColourDisplay + { + Current = { Value = Colours[colourIndex] } + }); + + display.Current.BindValueChanged(colour => Colours[colourIndex] = colour.NewValue); + display.DeleteRequested += colourDeletionRequested; } - foreach (var item in Colours) + palette.Add(new AddColourButton { - palette.Add(new ColourDisplay - { - Current = { Value = item } - }); - } + Action = () => Colours.Add(Colour4.White) + }); reindexItems(); } + private void colourDeletionRequested(ColourDisplay display) => Colours.RemoveAt(palette.IndexOf(display)); + private void reindexItems() { int index = 1; - foreach (var colour in palette) + foreach (var colourDisplay in colourDisplays) { - colour.ColourName = $"{colourNamePrefix} {index}"; + colourDisplay.ColourName = $"{colourNamePrefix} {index}"; index += 1; } } + + internal class AddColourButton : CompositeDrawable + { + public Action Action + { + set => circularButton.Action = value; + } + + private readonly OsuClickableContainer circularButton; + + public AddColourButton() + { + AutoSizeAxes = Axes.Y; + Width = 100; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + circularButton = new OsuClickableContainer + { + RelativeSizeAxes = Axes.X, + Height = 100, + CornerRadius = 50, + Masking = true, + BorderThickness = 5, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Transparent, + AlwaysPresent = true + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(20), + Icon = FontAwesome.Solid.Plus + } + } + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "New" + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + circularButton.BorderColour = colours.BlueDarker; + } + } } } 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/LabelledColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs index 58443953bc..8970ef1115 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osuTK.Graphics; +using osu.Framework.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -13,7 +13,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 { } - public BindableList Colours => Component.Colours; + public BindableList Colours => Component.Colours; public string ColourNamePrefix { diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs new file mode 100644 index 0000000000..44f09f13eb --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class LabelledDropdown : LabelledComponent, TItem> + { + public LabelledDropdown() + : base(true) + { + } + + public IEnumerable Items + { + get => Component.Items; + set => Component.Items = value; + } + + protected sealed override OsuDropdown CreateComponent() => CreateDropdown().With(d => + { + d.RelativeSizeAxes = Axes.X; + d.Width = 0.5f; + }); + + protected virtual OsuDropdown CreateDropdown() => new OsuDropdown(); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs new file mode 100644 index 0000000000..b818c394ae --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.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; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class LabelledEnumDropdown : LabelledDropdown + where TEnum : struct, Enum + { + protected override OsuDropdown CreateDropdown() => new OsuEnumDropdown(); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledNumberBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledNumberBox.cs new file mode 100644 index 0000000000..ca247ab679 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledNumberBox.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.Graphics.UserInterface; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class LabelledNumberBox : LabelledTextBox + { + protected override OsuTextBox CreateTextBox() => new OsuNumberBox(); + } +} 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..618c7dabfa --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.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.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; +using osu.Game.Graphics.UserInterface; + +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; + + AddRangeInternal(new Drawable[] + { + new Background + { + Depth = 1 + }, + new HoverClickSounds() + }); + } + + 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..3d09d09833 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -0,0 +1,95 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.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; +using osu.Game.Graphics.UserInterface; + +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; + + AddRangeInternal(new Drawable[] + { + new OsuDirectorySelectorDirectory.Background + { + Depth = 1 + }, + new HoverClickSounds() + }); + } + + 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 index 06056f239b..30e38e8938 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs @@ -89,8 +89,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 { 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(); diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs new file mode 100644 index 0000000000..226c39c030 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class OsuPopover : Popover, IKeyBindingHandler + { + 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); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (State.Value == Visibility.Hidden) + return false; + + if (e.Action == GlobalAction.Back) + { + Hide(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs new file mode 100644 index 0000000000..27e28f1e03 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class RoundedButton : OsuButton, IFilterable + { + public override float Height + { + get => base.Height; + set + { + base.Height = value; + + if (IsLoaded) + updateCornerRadius(); + } + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.Blue3; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateCornerRadius(); + } + + private void updateCornerRadius() => Content.CornerRadius = DrawHeight / 2; + + public virtual IEnumerable FilterTerms => new[] { Text.ToString() }; + + public bool MatchingFilter + { + set => this.FadeTo(value ? 1 : 0); + } + + public bool FilteringActive { get; set; } + } +} diff --git a/osu.Game/IO/FileInfo.cs b/osu.Game/IO/FileInfo.cs index e04bfb46cc..331546f9f8 100644 --- a/osu.Game/IO/FileInfo.cs +++ b/osu.Game/IO/FileInfo.cs @@ -6,7 +6,7 @@ using osu.Game.Database; namespace osu.Game.IO { - public class FileInfo : IHasPrimaryKey + public class FileInfo : IHasPrimaryKey, IFileInfo { public int ID { get; set; } diff --git a/osu.Game/IO/IFileInfo.cs b/osu.Game/IO/IFileInfo.cs new file mode 100644 index 0000000000..080d8e57f5 --- /dev/null +++ b/osu.Game/IO/IFileInfo.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. + +#nullable enable + +namespace osu.Game.IO +{ + /// + /// A representation of a tracked file. + /// + public interface IFileInfo + { + /// + /// SHA-256 hash of the file content. + /// + string Hash { get; } + } +} diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 75130b0f9b..802c71e363 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -102,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/IO/Serialization/IJsonSerializable.cs b/osu.Game/IO/Serialization/JsonSerializableExtensions.cs similarity index 72% rename from osu.Game/IO/Serialization/IJsonSerializable.cs rename to osu.Game/IO/Serialization/JsonSerializableExtensions.cs index c8d5ce39a6..68b2110ede 100644 --- a/osu.Game/IO/Serialization/IJsonSerializable.cs +++ b/osu.Game/IO/Serialization/JsonSerializableExtensions.cs @@ -7,21 +7,14 @@ using osu.Framework.IO.Serialization; namespace osu.Game.IO.Serialization { - public interface IJsonSerializable - { - } - public static class JsonSerializableExtensions { - public static string Serialize(this IJsonSerializable obj) => JsonConvert.SerializeObject(obj, CreateGlobalSettings()); + public static string Serialize(this object obj) => JsonConvert.SerializeObject(obj, CreateGlobalSettings()); public static T Deserialize(this string objString) => JsonConvert.DeserializeObject(objString, CreateGlobalSettings()); public static void DeserializeInto(this string objString, T target) => JsonConvert.PopulateObject(objString, target, CreateGlobalSettings()); - /// - /// Creates the default that should be used for all s. - /// public static JsonSerializerSettings CreateGlobalSettings() => new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, @@ -29,7 +22,7 @@ namespace osu.Game.IO.Serialization ObjectCreationHandling = ObjectCreationHandling.Replace, DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, Converters = new List { new Vector2Converter() }, - ContractResolver = new KeyContractResolver() + ContractResolver = new SnakeCaseKeyContractResolver() }; } } diff --git a/osu.Game/IO/Serialization/KeyContractResolver.cs b/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs similarity index 84% rename from osu.Game/IO/Serialization/KeyContractResolver.cs rename to osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs index 7b6905643c..68cb66d0dc 100644 --- a/osu.Game/IO/Serialization/KeyContractResolver.cs +++ b/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs @@ -6,7 +6,7 @@ using Newtonsoft.Json.Serialization; namespace osu.Game.IO.Serialization { - public class KeyContractResolver : DefaultContractResolver + public class SnakeCaseKeyContractResolver : DefaultContractResolver { protected override string ResolvePropertyName(string propertyName) { diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs index 5b2549d2ee..b9ccc907d9 100644 --- a/osu.Game/IO/WrappedStorage.cs +++ b/osu.Game/IO/WrappedStorage.cs @@ -70,11 +70,6 @@ namespace osu.Game.IO public override Stream GetStream(string path, FileAccess access = FileAccess.Read, FileMode mode = FileMode.OpenOrCreate) => UnderlyingStorage.GetStream(MutatePath(path), access, mode); - public override string GetDatabaseConnectionString(string name) => - UnderlyingStorage.GetDatabaseConnectionString(MutatePath(name)); - - public override void DeleteDatabase(string name) => UnderlyingStorage.DeleteDatabase(MutatePath(name)); - public override void OpenPathInNativeExplorer(string path) => UnderlyingStorage.OpenPathInNativeExplorer(MutatePath(path)); public override Storage GetStorageForDirectory(string path) diff --git a/osu.Game/Input/Bindings/DatabasedKeyBinding.cs b/osu.Game/Input/Bindings/DatabasedKeyBinding.cs deleted file mode 100644 index ad3493d0fc..0000000000 --- a/osu.Game/Input/Bindings/DatabasedKeyBinding.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.ComponentModel.DataAnnotations.Schema; -using osu.Framework.Input.Bindings; -using osu.Game.Database; - -namespace osu.Game.Input.Bindings -{ - [Table("KeyBinding")] - public class DatabasedKeyBinding : IKeyBinding, IHasPrimaryKey - { - public int ID { get; set; } - - public int? RulesetID { get; set; } - - public int? Variant { get; set; } - - [Column("Keys")] - public string KeysString { get; set; } - - [Column("Action")] - public int IntAction { get; set; } - - [NotMapped] - public KeyCombination KeyCombination - { - get => KeysString; - set => KeysString = value.ToString(); - } - - [NotMapped] - public object Action - { - get => IntAction; - set => IntAction = (int)value; - } - } -} diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index c8227c0887..9fd7caadd0 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -2,11 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.ComponentModel; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Input.Bindings { @@ -74,6 +75,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.A }, GlobalAction.EditorVerifyMode), new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), + new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), }; public IEnumerable InGameKeyBindings => new[] @@ -87,7 +89,10 @@ 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), + new KeyBinding(InputKey.Tab, GlobalAction.ToggleChatFocus), }; public IEnumerable SongSelectKeyBindings => new[] @@ -103,6 +108,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), @@ -131,137 +139,155 @@ namespace osu.Game.Input.Bindings public enum GlobalAction { - [Description("Toggle chat overlay")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChat))] ToggleChat, - [Description("Toggle social overlay")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleSocial))] ToggleSocial, - [Description("Reset input settings")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ResetInputSettings))] ResetInputSettings, - [Description("Toggle toolbar")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleToolbar))] ToggleToolbar, - [Description("Toggle settings")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleSettings))] ToggleSettings, - [Description("Toggle beatmap listing")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleBeatmapListing))] ToggleBeatmapListing, - [Description("Increase volume")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.IncreaseVolume))] IncreaseVolume, - [Description("Decrease volume")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.DecreaseVolume))] DecreaseVolume, - [Description("Toggle mute")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleMute))] ToggleMute, // In-Game Keybindings - [Description("Skip cutscene")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SkipCutscene))] SkipCutscene, - [Description("Quick retry (hold)")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.QuickRetry))] QuickRetry, - [Description("Take screenshot")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.TakeScreenshot))] TakeScreenshot, - [Description("Toggle gameplay mouse buttons")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleGameplayMouseButtons))] ToggleGameplayMouseButtons, - [Description("Back")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.Back))] Back, - [Description("Increase scroll speed")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.IncreaseScrollSpeed))] IncreaseScrollSpeed, - [Description("Decrease scroll speed")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.DecreaseScrollSpeed))] DecreaseScrollSpeed, - [Description("Select")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.Select))] Select, - [Description("Quick exit (hold)")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.QuickExit))] QuickExit, // Game-wide beatmap music controller keybindings - [Description("Next track")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.MusicNext))] MusicNext, - [Description("Previous track")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.MusicPrev))] MusicPrev, - [Description("Play / pause")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.MusicPlay))] MusicPlay, - [Description("Toggle now playing overlay")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleNowPlaying))] ToggleNowPlaying, - [Description("Previous selection")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectPrevious))] SelectPrevious, - [Description("Next selection")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectNext))] SelectNext, - [Description("Home")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.Home))] Home, - [Description("Toggle notifications")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleNotifications))] ToggleNotifications, - [Description("Pause gameplay")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.PauseGameplay))] PauseGameplay, // Editor - [Description("Setup mode")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSetupMode))] EditorSetupMode, - [Description("Compose mode")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorComposeMode))] EditorComposeMode, - [Description("Design mode")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorDesignMode))] EditorDesignMode, - [Description("Timing mode")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTimingMode))] EditorTimingMode, - [Description("Hold for HUD")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.HoldForHUD))] HoldForHUD, - [Description("Random skin")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.RandomSkin))] RandomSkin, - [Description("Pause / resume replay")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.TogglePauseReplay))] TogglePauseReplay, - [Description("Toggle in-game interface")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameInterface))] ToggleInGameInterface, // Song select keybindings - [Description("Toggle Mod Select")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleModSelection))] ToggleModSelection, - [Description("Random")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectNextRandom))] SelectNextRandom, - [Description("Rewind")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectPreviousRandom))] SelectPreviousRandom, - [Description("Beatmap Options")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleBeatmapOptions))] ToggleBeatmapOptions, - [Description("Verify mode")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorVerifyMode))] EditorVerifyMode, - [Description("Nudge selection left")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorNudgeLeft))] EditorNudgeLeft, - [Description("Nudge selection right")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorNudgeRight))] EditorNudgeRight, - [Description("Toggle skin editor")] + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleSkinEditor))] ToggleSkinEditor, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.PreviousVolumeMeter))] + PreviousVolumeMeter, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.NextVolumeMeter))] + NextVolumeMeter, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SeekReplayForward))] + SeekReplayForward, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SeekReplayBackward))] + SeekReplayBackward, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChatFocus))] + ToggleChatFocus, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridDisplayMode))] + EditorCycleGridDisplayMode } } diff --git a/osu.Game/Input/ConfineMouseTracker.cs b/osu.Game/Input/ConfineMouseTracker.cs index 75d9c8debb..d2bf953dbc 100644 --- a/osu.Game/Input/ConfineMouseTracker.cs +++ b/osu.Game/Input/ConfineMouseTracker.cs @@ -7,6 +7,7 @@ using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Game.Configuration; +using osu.Game.Screens.Play; namespace osu.Game.Input { @@ -24,14 +25,14 @@ namespace osu.Game.Input private IBindable localUserPlaying; [BackgroundDependencyLoader] - private void load(OsuGame game, FrameworkConfigManager frameworkConfigManager, OsuConfigManager osuConfigManager) + private void load(ILocalUserPlayInfo localUserInfo, FrameworkConfigManager frameworkConfigManager, OsuConfigManager osuConfigManager) { frameworkConfineMode = frameworkConfigManager.GetBindable(FrameworkSetting.ConfineMouseMode); frameworkWindowMode = frameworkConfigManager.GetBindable(FrameworkSetting.WindowMode); frameworkWindowMode.BindValueChanged(_ => updateConfineMode()); osuConfineMode = osuConfigManager.GetBindable(OsuSetting.ConfineMouseMode); - localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); + localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy(); osuConfineMode.ValueChanged += _ => updateConfineMode(); localUserPlaying.BindValueChanged(_ => updateConfineMode(), true); diff --git a/osu.Game/Input/Handlers/ReplayInputHandler.cs b/osu.Game/Input/Handlers/ReplayInputHandler.cs index cd76000f98..e4aec4edac 100644 --- a/osu.Game/Input/Handlers/ReplayInputHandler.cs +++ b/osu.Game/Input/Handlers/ReplayInputHandler.cs @@ -42,9 +42,24 @@ namespace osu.Game.Input.Handlers if (!(state is RulesetInputManagerInputState inputState)) throw new InvalidOperationException($"{nameof(ReplayState)} should only be applied to a {nameof(RulesetInputManagerInputState)}"); - var lastPressed = inputState.LastReplayState?.PressedActions ?? new List(); - var released = lastPressed.Except(PressedActions).ToArray(); - var pressed = PressedActions.Except(lastPressed).ToArray(); + T[] released = Array.Empty(); + T[] pressed = Array.Empty(); + + var lastPressed = inputState.LastReplayState?.PressedActions; + + if (lastPressed == null || lastPressed.Count == 0) + { + pressed = PressedActions.ToArray(); + } + else if (PressedActions.Count == 0) + { + released = lastPressed.ToArray(); + } + else if (!lastPressed.SequenceEqual(PressedActions)) + { + released = lastPressed.Except(PressedActions).ToArray(); + pressed = PressedActions.Except(lastPressed).ToArray(); + } inputState.LastReplayState = this; diff --git a/osu.Game/Input/IdleTracker.cs b/osu.Game/Input/IdleTracker.cs index f3d531cf6c..bfe21f650a 100644 --- a/osu.Game/Input/IdleTracker.cs +++ b/osu.Game/Input/IdleTracker.cs @@ -55,13 +55,13 @@ namespace osu.Game.Input isIdle.Value = TimeSpentIdle > timeToIdle && AllowIdle; } - public bool OnPressed(PlatformAction action) => updateLastInteractionTime(); + public bool OnPressed(KeyBindingPressEvent e) => updateLastInteractionTime(); - public void OnReleased(PlatformAction action) => updateLastInteractionTime(); + public void OnReleased(KeyBindingReleaseEvent e) => updateLastInteractionTime(); - public bool OnPressed(GlobalAction action) => updateLastInteractionTime(); + public bool OnPressed(KeyBindingPressEvent e) => updateLastInteractionTime(); - public void OnReleased(GlobalAction action) => updateLastInteractionTime(); + public void OnReleased(KeyBindingReleaseEvent e) => updateLastInteractionTime(); protected override bool Handle(UIEvent e) { diff --git a/osu.Game/Input/RealmKeyBindingStore.cs b/osu.Game/Input/RealmKeyBindingStore.cs index 9089169877..5fa3ccdeb9 100644 --- a/osu.Game/Input/RealmKeyBindingStore.cs +++ b/osu.Game/Input/RealmKeyBindingStore.cs @@ -7,6 +7,7 @@ using osu.Framework.Input.Bindings; using osu.Game.Database; using osu.Game.Input.Bindings; using osu.Game.Rulesets; +using Realms; #nullable enable @@ -30,9 +31,9 @@ namespace osu.Game.Input { List combinations = new List(); - using (var context = realmFactory.GetForRead()) + using (var context = realmFactory.CreateContext()) { - foreach (var action in context.Realm.All().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction)) + foreach (var action in context.All().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction)) { string str = action.KeyCombination.ReadableString(); @@ -46,49 +47,51 @@ namespace osu.Game.Input } /// - /// Register a new type of , adding default bindings from . + /// Register all defaults for this store. /// /// 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) + /// The rulesets to populate defaults from. + public void Register(KeyBindingContainer container, IEnumerable rulesets) { - 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()) + using (var realm = realmFactory.CreateContext()) + using (var transaction = realm.BeginWrite()) { - // compare counts in database vs defaults - foreach (var defaultsForAction in defaults.GroupBy(k => k.Action)) + // intentionally flattened to a list rather than querying against the IQueryable, as nullable fields being queried against aren't indexed. + // this is much faster as a result. + var existingBindings = realm.All().ToList(); + + insertDefaults(realm, existingBindings, container.DefaultKeyBindings); + + foreach (var ruleset in rulesets) { - 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 - }); - } + var instance = ruleset.CreateInstance(); + foreach (var variant in instance.AvailableVariants) + insertDefaults(realm, existingBindings, instance.GetDefaultKeyBindings(variant), ruleset.ID, variant); } - usage.Commit(); + transaction.Commit(); + } + } + + private void insertDefaults(Realm realm, List existingBindings, IEnumerable defaults, int? rulesetId = null, int? variant = null) + { + // compare counts in database vs defaults for each action type. + foreach (var defaultsForAction in defaults.GroupBy(k => k.Action)) + { + // avoid performing redundant queries when the database is empty and needs to be re-filled. + int existingCount = existingBindings.Count(k => k.RulesetID == rulesetId && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key); + + if (defaultsForAction.Count() <= existingCount) + continue; + + // insert any defaults which are missing. + realm.Add(defaultsForAction.Skip(existingCount).Select(k => new RealmKeyBinding + { + KeyCombinationString = k.KeyCombination.ToString(), + ActionInt = (int)k.Action, + RulesetID = rulesetId, + Variant = variant + })); } } diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs new file mode 100644 index 0000000000..008781c2e5 --- /dev/null +++ b/osu.Game/Localisation/AudioSettingsStrings.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class AudioSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.AudioSettings"; + + /// + /// "Audio" + /// + public static LocalisableString AudioSectionHeader => new TranslatableString(getKey(@"audio_section_header"), @"Audio"); + + /// + /// "Devices" + /// + public static LocalisableString AudioDevicesHeader => new TranslatableString(getKey(@"audio_devices_header"), @"Devices"); + + /// + /// "Volume" + /// + public static LocalisableString VolumeHeader => new TranslatableString(getKey(@"volume_header"), @"Volume"); + + /// + /// "Output device" + /// + public static LocalisableString OutputDevice => new TranslatableString(getKey(@"output_device"), @"Output device"); + + /// + /// "Master" + /// + public static LocalisableString MasterVolume => new TranslatableString(getKey(@"master_volume"), @"Master"); + + /// + /// "Master (window inactive)" + /// + public static LocalisableString MasterVolumeInactive => new TranslatableString(getKey(@"master_volume_inactive"), @"Master (window inactive)"); + + /// + /// "Effect" + /// + public static LocalisableString EffectVolume => new TranslatableString(getKey(@"effect_volume"), @"Effect"); + + /// + /// "Music" + /// + public static LocalisableString MusicVolume => new TranslatableString(getKey(@"music_volume"), @"Music"); + + /// + /// "Offset Adjustment" + /// + public static LocalisableString OffsetHeader => new TranslatableString(getKey(@"offset_header"), @"Offset Adjustment"); + + /// + /// "Audio offset" + /// + public static LocalisableString AudioOffset => new TranslatableString(getKey(@"audio_offset"), @"Audio offset"); + + /// + /// "Offset wizard" + /// + public static LocalisableString OffsetWizard => new TranslatableString(getKey(@"offset_wizard"), @"Offset wizard"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} 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 636351470b..7bd284a94e 100644 --- a/osu.Game/Localisation/ChatStrings.cs +++ b/osu.Game/Localisation/ChatStrings.cs @@ -7,7 +7,7 @@ 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" 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 ced0d80955..33a6eb5d58 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -7,13 +7,48 @@ 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"); - private static string getKey(string key) => $"{prefix}:{key}"; + /// + /// "Clear" + /// + public static LocalisableString Clear => new TranslatableString(getKey(@"clear"), @"Clear"); + + /// + /// "Enabled" + /// + public static LocalisableString Enabled => new TranslatableString(getKey(@"enabled"), @"Enabled"); + + /// + /// "Default" + /// + public static LocalisableString Default => new TranslatableString(getKey(@"default"), @"Default"); + + /// + /// "Width" + /// + public static LocalisableString Width => new TranslatableString(getKey(@"width"), @"Width"); + + /// + /// "Height" + /// + public static LocalisableString Height => new TranslatableString(getKey(@"height"), @"Height"); + + /// + /// "Downloading..." + /// + public static LocalisableString Downloading => new TranslatableString(getKey(@"downloading"), @"Downloading..."); + + /// + /// "Importing..." + /// + public static LocalisableString Importing => new TranslatableString(getKey(@"importing"), @"Importing..."); + + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/DebugSettingsStrings.cs b/osu.Game/Localisation/DebugSettingsStrings.cs new file mode 100644 index 0000000000..dd21739096 --- /dev/null +++ b/osu.Game/Localisation/DebugSettingsStrings.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.Localisation; + +namespace osu.Game.Localisation +{ + public static class DebugSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.DebugSettings"; + + /// + /// "Debug" + /// + public static LocalisableString DebugSectionHeader => new TranslatableString(getKey(@"debug_section_header"), @"Debug"); + + /// + /// "General" + /// + public static LocalisableString GeneralHeader => new TranslatableString(getKey(@"general_header"), @"General"); + + /// + /// "Show log overlay" + /// + public static LocalisableString ShowLogOverlay => new TranslatableString(getKey(@"show_log_overlay"), @"Show log overlay"); + + /// + /// "Bypass front-to-back render pass" + /// + public static LocalisableString BypassFrontToBackPass => new TranslatableString(getKey(@"bypass_front_to_back_pass"), @"Bypass front-to-back render pass"); + + /// + /// "Import files" + /// + public static LocalisableString ImportFiles => new TranslatableString(getKey(@"import_files"), @"Import files"); + + /// + /// "Memory" + /// + public static LocalisableString MemoryHeader => new TranslatableString(getKey(@"memory_header"), @"Memory"); + + /// + /// "Clear all caches" + /// + public static LocalisableString ClearAllCaches => new TranslatableString(getKey(@"clear_all_caches"), @"Clear all caches"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs new file mode 100644 index 0000000000..fa92187650 --- /dev/null +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class GameplaySettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.GameplaySettings"; + + /// + /// "Gameplay" + /// + public static LocalisableString GameplaySectionHeader => new TranslatableString(getKey(@"gameplay_section_header"), @"Gameplay"); + + /// + /// "Beatmap" + /// + public static LocalisableString BeatmapHeader => new TranslatableString(getKey(@"beatmap_header"), @"Beatmap"); + + /// + /// "General" + /// + public static LocalisableString GeneralHeader => new TranslatableString(getKey(@"general_header"), @"General"); + + /// + /// "Audio" + /// + public static LocalisableString AudioHeader => new TranslatableString(getKey(@"audio"), @"Audio"); + + /// + /// "HUD" + /// + public static LocalisableString HUDHeader => new TranslatableString(getKey(@"h_u_d"), @"HUD"); + + /// + /// "Input" + /// + public static LocalisableString InputHeader => new TranslatableString(getKey(@"input"), @"Input"); + + /// + /// "Background" + /// + public static LocalisableString BackgroundHeader => new TranslatableString(getKey(@"background"), @"Background"); + + /// + /// "Background dim" + /// + public static LocalisableString BackgroundDim => new TranslatableString(getKey(@"dim"), @"Background dim"); + + /// + /// "Background blur" + /// + public static LocalisableString BackgroundBlur => new TranslatableString(getKey(@"blur"), @"Background blur"); + + /// + /// "Lighten playfield during breaks" + /// + public static LocalisableString LightenDuringBreaks => new TranslatableString(getKey(@"lighten_during_breaks"), @"Lighten playfield during breaks"); + + /// + /// "HUD overlay visibility mode" + /// + public static LocalisableString HUDVisibilityMode => new TranslatableString(getKey(@"hud_visibility_mode"), @"HUD overlay visibility mode"); + + /// + /// "Show difficulty graph on progress bar" + /// + public static LocalisableString ShowDifficultyGraph => new TranslatableString(getKey(@"show_difficulty_graph"), @"Show difficulty graph on progress bar"); + + /// + /// "Show health display even when you can't fail" + /// + public static LocalisableString ShowHealthDisplayWhenCantFail => new TranslatableString(getKey(@"show_health_display_when_cant_fail"), @"Show health display even when you can't fail"); + + /// + /// "Fade playfield to red when health is low" + /// + public static LocalisableString FadePlayfieldWhenHealthLow => new TranslatableString(getKey(@"fade_playfield_when_health_low"), @"Fade playfield to red when health is low"); + + /// + /// "Always show key overlay" + /// + public static LocalisableString AlwaysShowKeyOverlay => new TranslatableString(getKey(@"key_overlay"), @"Always show key overlay"); + + /// + /// "Positional hitsounds" + /// + public static LocalisableString PositionalHitsounds => new TranslatableString(getKey(@"positional_hitsounds"), @"Positional hitsounds"); + + /// + /// "Always play first combo break sound" + /// + public static LocalisableString AlwaysPlayFirstComboBreak => new TranslatableString(getKey(@"always_play_first_combo_break"), @"Always play first combo break sound"); + + /// + /// "Score display mode" + /// + public static LocalisableString ScoreDisplayMode => new TranslatableString(getKey(@"score_display_mode"), @"Score display mode"); + + /// + /// "Disable Windows key during gameplay" + /// + public static LocalisableString DisableWinKey => new TranslatableString(getKey(@"disable_win_key"), @"Disable Windows key during gameplay"); + + /// + /// "Mods" + /// + public static LocalisableString ModsHeader => new TranslatableString(getKey(@"mods_header"), @"Mods"); + + /// + /// "Increase visibility of first object when visual impairment mods are enabled" + /// + public static LocalisableString IncreaseFirstObjectVisibility => new TranslatableString(getKey(@"increase_first_object_visibility"), @"Increase visibility of first object when visual impairment mods are enabled"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs new file mode 100644 index 0000000000..a60e4891f4 --- /dev/null +++ b/osu.Game/Localisation/GeneralSettingsStrings.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 GeneralSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.GeneralSettings"; + + /// + /// "General" + /// + public static LocalisableString GeneralSectionHeader => new TranslatableString(getKey(@"general_section_header"), @"General"); + + /// + /// "Language" + /// + public static LocalisableString LanguageHeader => new TranslatableString(getKey(@"language_header"), @"Language"); + + /// + /// "Language" + /// + public static LocalisableString LanguageDropdown => new TranslatableString(getKey(@"language_dropdown"), @"Language"); + + /// + /// "Prefer metadata in original language" + /// + public static LocalisableString PreferOriginalMetadataLanguage => new TranslatableString(getKey(@"prefer_original"), @"Prefer metadata in original language"); + + /// + /// "Updates" + /// + public static LocalisableString UpdateHeader => new TranslatableString(getKey(@"update_header"), @"Updates"); + + /// + /// "Release stream" + /// + public static LocalisableString ReleaseStream => new TranslatableString(getKey(@"release_stream"), @"Release stream"); + + /// + /// "Check for updates" + /// + public static LocalisableString CheckUpdate => new TranslatableString(getKey(@"check_update"), @"Check for updates"); + + /// + /// "Open osu! folder" + /// + public static LocalisableString OpenOsuFolder => new TranslatableString(getKey(@"open_osu_folder"), @"Open osu! folder"); + + /// + /// "Change folder location..." + /// + public static LocalisableString ChangeFolderLocation => new TranslatableString(getKey(@"change_folder_location"), @"Change folder location..."); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs new file mode 100644 index 0000000000..06f1b094bf --- /dev/null +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -0,0 +1,259 @@ +// Copyright (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 GlobalActionKeyBindingStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.GlobalActionKeyBinding"; + + /// + /// "Toggle chat overlay" + /// + public static LocalisableString ToggleChat => new TranslatableString(getKey(@"toggle_chat"), @"Toggle chat overlay"); + + /// + /// "Toggle social overlay" + /// + public static LocalisableString ToggleSocial => new TranslatableString(getKey(@"toggle_social"), @"Toggle social overlay"); + + /// + /// "Reset input settings" + /// + public static LocalisableString ResetInputSettings => new TranslatableString(getKey(@"reset_input_settings"), @"Reset input settings"); + + /// + /// "Toggle toolbar" + /// + public static LocalisableString ToggleToolbar => new TranslatableString(getKey(@"toggle_toolbar"), @"Toggle toolbar"); + + /// + /// "Toggle settings" + /// + public static LocalisableString ToggleSettings => new TranslatableString(getKey(@"toggle_settings"), @"Toggle settings"); + + /// + /// "Toggle beatmap listing" + /// + public static LocalisableString ToggleBeatmapListing => new TranslatableString(getKey(@"toggle_beatmap_listing"), @"Toggle beatmap listing"); + + /// + /// "Increase volume" + /// + public static LocalisableString IncreaseVolume => new TranslatableString(getKey(@"increase_volume"), @"Increase volume"); + + /// + /// "Decrease volume" + /// + public static LocalisableString DecreaseVolume => new TranslatableString(getKey(@"decrease_volume"), @"Decrease volume"); + + /// + /// "Toggle mute" + /// + public static LocalisableString ToggleMute => new TranslatableString(getKey(@"toggle_mute"), @"Toggle mute"); + + /// + /// "Skip cutscene" + /// + public static LocalisableString SkipCutscene => new TranslatableString(getKey(@"skip_cutscene"), @"Skip cutscene"); + + /// + /// "Quick retry (hold)" + /// + public static LocalisableString QuickRetry => new TranslatableString(getKey(@"quick_retry"), @"Quick retry (hold)"); + + /// + /// "Take screenshot" + /// + public static LocalisableString TakeScreenshot => new TranslatableString(getKey(@"take_screenshot"), @"Take screenshot"); + + /// + /// "Toggle gameplay mouse buttons" + /// + public static LocalisableString ToggleGameplayMouseButtons => new TranslatableString(getKey(@"toggle_gameplay_mouse_buttons"), @"Toggle gameplay mouse buttons"); + + /// + /// "Back" + /// + public static LocalisableString Back => new TranslatableString(getKey(@"back"), @"Back"); + + /// + /// "Increase scroll speed" + /// + public static LocalisableString IncreaseScrollSpeed => new TranslatableString(getKey(@"increase_scroll_speed"), @"Increase scroll speed"); + + /// + /// "Decrease scroll speed" + /// + public static LocalisableString DecreaseScrollSpeed => new TranslatableString(getKey(@"decrease_scroll_speed"), @"Decrease scroll speed"); + + /// + /// "Select" + /// + public static LocalisableString Select => new TranslatableString(getKey(@"select"), @"Select"); + + /// + /// "Quick exit (hold)" + /// + public static LocalisableString QuickExit => new TranslatableString(getKey(@"quick_exit"), @"Quick exit (hold)"); + + /// + /// "Next track" + /// + public static LocalisableString MusicNext => new TranslatableString(getKey(@"music_next"), @"Next track"); + + /// + /// "Previous track" + /// + public static LocalisableString MusicPrev => new TranslatableString(getKey(@"music_prev"), @"Previous track"); + + /// + /// "Play / pause" + /// + public static LocalisableString MusicPlay => new TranslatableString(getKey(@"music_play"), @"Play / pause"); + + /// + /// "Toggle now playing overlay" + /// + public static LocalisableString ToggleNowPlaying => new TranslatableString(getKey(@"toggle_now_playing"), @"Toggle now playing overlay"); + + /// + /// "Previous selection" + /// + public static LocalisableString SelectPrevious => new TranslatableString(getKey(@"select_previous"), @"Previous selection"); + + /// + /// "Next selection" + /// + public static LocalisableString SelectNext => new TranslatableString(getKey(@"select_next"), @"Next selection"); + + /// + /// "Home" + /// + public static LocalisableString Home => new TranslatableString(getKey(@"home"), @"Home"); + + /// + /// "Toggle notifications" + /// + public static LocalisableString ToggleNotifications => new TranslatableString(getKey(@"toggle_notifications"), @"Toggle notifications"); + + /// + /// "Pause gameplay" + /// + public static LocalisableString PauseGameplay => new TranslatableString(getKey(@"pause_gameplay"), @"Pause gameplay"); + + /// + /// "Setup mode" + /// + public static LocalisableString EditorSetupMode => new TranslatableString(getKey(@"editor_setup_mode"), @"Setup mode"); + + /// + /// "Compose mode" + /// + public static LocalisableString EditorComposeMode => new TranslatableString(getKey(@"editor_compose_mode"), @"Compose mode"); + + /// + /// "Design mode" + /// + public static LocalisableString EditorDesignMode => new TranslatableString(getKey(@"editor_design_mode"), @"Design mode"); + + /// + /// "Timing mode" + /// + public static LocalisableString EditorTimingMode => new TranslatableString(getKey(@"editor_timing_mode"), @"Timing mode"); + + /// + /// "Cycle grid display mode" + /// + public static LocalisableString EditorCycleGridDisplayMode => new TranslatableString(getKey(@"editor_cycle_grid_display_mode"), @"Cycle grid display mode"); + + /// + /// "Hold for HUD" + /// + public static LocalisableString HoldForHUD => new TranslatableString(getKey(@"hold_for_hud"), @"Hold for HUD"); + + /// + /// "Random skin" + /// + public static LocalisableString RandomSkin => new TranslatableString(getKey(@"random_skin"), @"Random skin"); + + /// + /// "Pause / resume replay" + /// + public static LocalisableString TogglePauseReplay => new TranslatableString(getKey(@"toggle_pause_replay"), @"Pause / resume replay"); + + /// + /// "Toggle in-game interface" + /// + public static LocalisableString ToggleInGameInterface => new TranslatableString(getKey(@"toggle_in_game_interface"), @"Toggle in-game interface"); + + /// + /// "Toggle Mod Select" + /// + public static LocalisableString ToggleModSelection => new TranslatableString(getKey(@"toggle_mod_selection"), @"Toggle Mod Select"); + + /// + /// "Random" + /// + public static LocalisableString SelectNextRandom => new TranslatableString(getKey(@"select_next_random"), @"Random"); + + /// + /// "Rewind" + /// + public static LocalisableString SelectPreviousRandom => new TranslatableString(getKey(@"select_previous_random"), @"Rewind"); + + /// + /// "Beatmap Options" + /// + public static LocalisableString ToggleBeatmapOptions => new TranslatableString(getKey(@"toggle_beatmap_options"), @"Beatmap Options"); + + /// + /// "Verify mode" + /// + public static LocalisableString EditorVerifyMode => new TranslatableString(getKey(@"editor_verify_mode"), @"Verify mode"); + + /// + /// "Nudge selection left" + /// + public static LocalisableString EditorNudgeLeft => new TranslatableString(getKey(@"editor_nudge_left"), @"Nudge selection left"); + + /// + /// "Nudge selection right" + /// + public static LocalisableString EditorNudgeRight => new TranslatableString(getKey(@"editor_nudge_right"), @"Nudge selection right"); + + /// + /// "Toggle skin editor" + /// + public static LocalisableString ToggleSkinEditor => new TranslatableString(getKey(@"toggle_skin_editor"), @"Toggle skin editor"); + + /// + /// "Previous volume meter" + /// + public static LocalisableString PreviousVolumeMeter => new TranslatableString(getKey(@"previous_volume_meter"), @"Previous volume meter"); + + /// + /// "Next volume meter" + /// + public static LocalisableString NextVolumeMeter => new TranslatableString(getKey(@"next_volume_meter"), @"Next volume meter"); + + /// + /// "Seek replay forward" + /// + public static LocalisableString SeekReplayForward => new TranslatableString(getKey(@"seek_replay_forward"), @"Seek replay forward"); + + /// + /// "Seek replay backward" + /// + public static LocalisableString SeekReplayBackward => new TranslatableString(getKey(@"seek_replay_backward"), @"Seek replay backward"); + + /// + /// "Toggle chat focus" + /// + public static LocalisableString ToggleChatFocus => new TranslatableString(getKey(@"toggle_chat_focus"), @"Toggle chat focus"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs new file mode 100644 index 0000000000..f85cc0f2ae --- /dev/null +++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class GraphicsSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.GraphicsSettings"; + + /// + /// "Graphics" + /// + public static LocalisableString GraphicsSectionHeader => new TranslatableString(getKey(@"graphics_section_header"), @"Graphics"); + + /// + /// "Renderer" + /// + public static LocalisableString RendererHeader => new TranslatableString(getKey(@"renderer_header"), @"Renderer"); + + /// + /// "Frame limiter" + /// + public static LocalisableString FrameLimiter => new TranslatableString(getKey(@"frame_limiter"), @"Frame limiter"); + + /// + /// "Threading mode" + /// + public static LocalisableString ThreadingMode => new TranslatableString(getKey(@"threading_mode"), @"Threading mode"); + + /// + /// "Show FPS" + /// + public static LocalisableString ShowFPS => new TranslatableString(getKey(@"show_fps"), @"Show FPS"); + + /// + /// "Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. "2x refresh rate" is recommended." + /// + public static LocalisableString UnlimitedFramesNote => new TranslatableString(getKey(@"unlimited_frames_note"), @"Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. ""2x refresh rate"" is recommended."); + + /// + /// "Layout" + /// + public static LocalisableString LayoutHeader => new TranslatableString(getKey(@"layout_header"), @"Layout"); + + /// + /// "Screen mode" + /// + public static LocalisableString ScreenMode => new TranslatableString(getKey(@"screen_mode"), @"Screen mode"); + + /// + /// "Resolution" + /// + public static LocalisableString Resolution => new TranslatableString(getKey(@"resolution"), @"Resolution"); + + /// + /// "UI scaling" + /// + public static LocalisableString UIScaling => new TranslatableString(getKey(@"ui_scaling"), @"UI scaling"); + + /// + /// "Screen scaling" + /// + public static LocalisableString ScreenScaling => new TranslatableString(getKey(@"screen_scaling"), @"Screen scaling"); + + /// + /// "Horizontal position" + /// + public static LocalisableString HorizontalPosition => new TranslatableString(getKey(@"horizontal_position"), @"Horizontal position"); + + /// + /// "Vertical position" + /// + public static LocalisableString VerticalPosition => new TranslatableString(getKey(@"vertical_position"), @"Vertical position"); + + /// + /// "Horizontal scale" + /// + public static LocalisableString HorizontalScale => new TranslatableString(getKey(@"horizontal_scale"), @"Horizontal scale"); + + /// + /// "Vertical scale" + /// + public static LocalisableString VerticalScale => new TranslatableString(getKey(@"vertical_scale"), @"Vertical scale"); + + /// + /// "Running without fullscreen mode will increase your input latency!" + /// + public static LocalisableString NotFullscreenNote => new TranslatableString(getKey(@"not_fullscreen_note"), @"Running without fullscreen mode will increase your input latency!"); + + /// + /// "Detail Settings" + /// + public static LocalisableString DetailSettingsHeader => new TranslatableString(getKey(@"detail_settings_header"), @"Detail Settings"); + + /// + /// "Storyboard / video" + /// + public static LocalisableString StoryboardVideo => new TranslatableString(getKey(@"storyboard_video"), @"Storyboard / video"); + + /// + /// "Hit lighting" + /// + public static LocalisableString HitLighting => new TranslatableString(getKey(@"hit_lighting"), @"Hit lighting"); + + /// + /// "Screenshots" + /// + public static LocalisableString Screenshots => new TranslatableString(getKey(@"screenshots"), @"Screenshots"); + + /// + /// "Screenshot format" + /// + public static LocalisableString ScreenshotFormat => new TranslatableString(getKey(@"screenshot_format"), @"Screenshot format"); + + /// + /// "Show menu cursor in screenshots" + /// + public static LocalisableString ShowCursorInScreenshots => new TranslatableString(getKey(@"show_cursor_in_screenshots"), @"Show menu cursor in screenshots"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/InputSettingsStrings.cs b/osu.Game/Localisation/InputSettingsStrings.cs new file mode 100644 index 0000000000..e46b4cecf3 --- /dev/null +++ b/osu.Game/Localisation/InputSettingsStrings.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 InputSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.InputSettings"; + + /// + /// "Input" + /// + public static LocalisableString InputSectionHeader => new TranslatableString(getKey(@"input_section_header"), @"Input"); + + /// + /// "Global" + /// + public static LocalisableString GlobalKeyBindingHeader => new TranslatableString(getKey(@"global_key_binding_header"), @"Global"); + + /// + /// "Song Select" + /// + public static LocalisableString SongSelectSection => new TranslatableString(getKey(@"song_select_section"), @"Song Select"); + + /// + /// "In Game" + /// + public static LocalisableString InGameSection => new TranslatableString(getKey(@"in_game_section"), @"In Game"); + + /// + /// "Audio" + /// + public static LocalisableString AudioSection => new TranslatableString(getKey(@"audio_section"), @"Audio"); + + /// + /// "Editor" + /// + public static LocalisableString EditorSection => new TranslatableString(getKey(@"editor_section"), @"Editor"); + + /// + /// "Reset all bindings in section" + /// + public static LocalisableString ResetSectionButton => new TranslatableString(getKey(@"reset_section_button"), @"Reset all bindings in section"); + + /// + /// "key configuration" + /// + public static LocalisableString KeyBindingPanelHeader => new TranslatableString(getKey(@"key_binding_panel_header"), @"key configuration"); + + /// + /// "Customise your keys!" + /// + public static LocalisableString KeyBindingPanelDescription => new TranslatableString(getKey(@"key_binding_panel_description"), @"Customise your keys!"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/MaintenanceSettingsStrings.cs b/osu.Game/Localisation/MaintenanceSettingsStrings.cs new file mode 100644 index 0000000000..a0e1a9ddab --- /dev/null +++ b/osu.Game/Localisation/MaintenanceSettingsStrings.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class MaintenanceSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.MaintenanceSettings"; + + /// + /// "Maintenance" + /// + public static LocalisableString MaintenanceSectionHeader => new TranslatableString(getKey(@"maintenance_section_header"), @"Maintenance"); + + /// + /// "Select directory" + /// + public static LocalisableString SelectDirectory => new TranslatableString(getKey(@"select_directory"), @"Select directory"); + + /// + /// "Import beatmaps from stable" + /// + public static LocalisableString ImportBeatmapsFromStable => new TranslatableString(getKey(@"import_beatmaps_from_stable"), @"Import beatmaps from stable"); + + /// + /// "Delete ALL beatmaps" + /// + public static LocalisableString DeleteAllBeatmaps => new TranslatableString(getKey(@"delete_all_beatmaps"), @"Delete ALL beatmaps"); + + /// + /// "Import scores from stable" + /// + public static LocalisableString ImportScoresFromStable => new TranslatableString(getKey(@"import_scores_from_stable"), @"Import scores from stable"); + + /// + /// "Delete ALL scores" + /// + public static LocalisableString DeleteAllScores => new TranslatableString(getKey(@"delete_all_scores"), @"Delete ALL scores"); + + /// + /// "Import skins from stable" + /// + public static LocalisableString ImportSkinsFromStable => new TranslatableString(getKey(@"import_skins_from_stable"), @"Import skins from stable"); + + /// + /// "Delete ALL skins" + /// + public static LocalisableString DeleteAllSkins => new TranslatableString(getKey(@"delete_all_skins"), @"Delete ALL skins"); + + /// + /// "Import collections from stable" + /// + public static LocalisableString ImportCollectionsFromStable => new TranslatableString(getKey(@"import_collections_from_stable"), @"Import collections from stable"); + + /// + /// "Delete ALL collections" + /// + public static LocalisableString DeleteAllCollections => new TranslatableString(getKey(@"delete_all_collections"), @"Delete ALL collections"); + + /// + /// "Restore all hidden difficulties" + /// + public static LocalisableString RestoreAllHiddenDifficulties => new TranslatableString(getKey(@"restore_all_hidden_difficulties"), @"Restore all hidden difficulties"); + + /// + /// "Restore all recently deleted beatmaps" + /// + public static LocalisableString RestoreAllRecentlyDeletedBeatmaps => new TranslatableString(getKey(@"restore_all_recently_deleted_beatmaps"), @"Restore all recently deleted beatmaps"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs new file mode 100644 index 0000000000..5e894c4e0b --- /dev/null +++ b/osu.Game/Localisation/MouseSettingsStrings.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 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"); + + /// + /// "This setting has known issues on your platform. If you encounter problems, it is recommended to adjust sensitivity externally and keep this disabled for now." + /// + public static LocalisableString HighPrecisionPlatformWarning => new TranslatableString(getKey(@"high_precision_platform_warning"), @"This setting has known issues on your platform. If you encounter problems, it is recommended to adjust sensitivity externally and keep this disabled for now."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/MultiplayerTeamResultsScreenStrings.cs b/osu.Game/Localisation/MultiplayerTeamResultsScreenStrings.cs new file mode 100644 index 0000000000..111c068bbd --- /dev/null +++ b/osu.Game/Localisation/MultiplayerTeamResultsScreenStrings.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class MultiplayerTeamResultsScreenStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.MultiplayerTeamResultsScreen"; + + /// + /// "Team {0} wins!" + /// + public static LocalisableString TeamWins(string winner) => new TranslatableString(getKey(@"team_wins"), @"Team {0} wins!", winner); + + /// + /// "The teams are tied!" + /// + public static LocalisableString TheTeamsAreTied => new TranslatableString(getKey(@"the_teams_are_tied"), @"The teams are tied!"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/NamedOverlayComponentStrings.cs b/osu.Game/Localisation/NamedOverlayComponentStrings.cs new file mode 100644 index 0000000000..475bea2a4a --- /dev/null +++ b/osu.Game/Localisation/NamedOverlayComponentStrings.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.Localisation; + +namespace osu.Game.Localisation +{ + public static class NamedOverlayComponentStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.NamedOverlayComponent"; + + /// + /// "browse for new beatmaps" + /// + public static LocalisableString BeatmapListingDescription => new TranslatableString(getKey(@"beatmap_listing_description"), @"browse for new beatmaps"); + + /// + /// "track recent dev updates in the osu! ecosystem" + /// + public static LocalisableString ChangelogDescription => new TranslatableString(getKey(@"changelog_description"), @"track recent dev updates in the osu! ecosystem"); + + /// + /// "view your friends and other information" + /// + public static LocalisableString DashboardDescription => new TranslatableString(getKey(@"dashboard_description"), @"view your friends and other information"); + + /// + /// "find out who's the best right now" + /// + public static LocalisableString RankingsDescription => new TranslatableString(getKey(@"rankings_description"), @"find out who's the best right now"); + + /// + /// "get up-to-date on community happenings" + /// + public static LocalisableString NewsDescription => new TranslatableString(getKey(@"news_description"), @"get up-to-date on community happenings"); + + /// + /// "knowledge base" + /// + public static LocalisableString WikiDescription => new TranslatableString(getKey(@"wiki_description"), @"knowledge base"); + + 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 ba28ef5560..382e0d81f4 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -7,7 +7,7 @@ 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" 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 47646b0f68..f334637338 100644 --- a/osu.Game/Localisation/NowPlayingStrings.cs +++ b/osu.Game/Localisation/NowPlayingStrings.cs @@ -7,7 +7,7 @@ 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" diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs new file mode 100644 index 0000000000..6862f4ac2c --- /dev/null +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class OnlineSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.OnlineSettings"; + + /// + /// "Online" + /// + public static LocalisableString OnlineSectionHeader => new TranslatableString(getKey(@"online_section_header"), @"Online"); + + /// + /// "Alerts and Privacy" + /// + public static LocalisableString AlertsAndPrivacyHeader => new TranslatableString(getKey(@"alerts_and_privacy_header"), @"Alerts and Privacy"); + + /// + /// "Show a notification when someone mentions your name" + /// + public static LocalisableString NotifyOnMentioned => new TranslatableString(getKey(@"notify_on_mentioned"), @"Show a notification when someone mentions your name"); + + /// + /// "Show a notification when you receive a private message" + /// + public static LocalisableString NotifyOnPrivateMessage => new TranslatableString(getKey(@"notify_on_private_message"), @"Show a notification when you receive a private message"); + + /// + /// "Integrations" + /// + public static LocalisableString IntegrationsHeader => new TranslatableString(getKey(@"integrations_header"), @"Integrations"); + + /// + /// "Discord Rich Presence" + /// + public static LocalisableString DiscordRichPresence => new TranslatableString(getKey(@"discord_rich_presence"), @"Discord Rich Presence"); + + /// + /// "Web" + /// + public static LocalisableString WebHeader => new TranslatableString(getKey(@"web_header"), @"Web"); + + /// + /// "Warn about opening external links" + /// + public static LocalisableString ExternalLinkWarning => new TranslatableString(getKey(@"external_link_warning"), @"Warn about opening external links"); + + /// + /// "Prefer downloads without video" + /// + public static LocalisableString PreferNoVideo => new TranslatableString(getKey(@"prefer_no_video"), @"Prefer downloads without video"); + + /// + /// "Automatically download beatmaps when spectating" + /// + public static LocalisableString AutomaticallyDownloadWhenSpectating => new TranslatableString(getKey(@"automatically_download_when_spectating"), @"Automatically download beatmaps when spectating"); + + /// + /// "Show explicit content in search results" + /// + public static LocalisableString ShowExplicitContent => new TranslatableString(getKey(@"show_explicit_content"), @"Show explicit content in search results"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs new file mode 100644 index 0000000000..a356c9e20b --- /dev/null +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class RulesetSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.RulesetSettings"; + + /// + /// "Rulesets" + /// + public static LocalisableString Rulesets => new TranslatableString(getKey(@"rulesets"), @"Rulesets"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} 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 f4b417fa28..aa2e2740eb 100644 --- a/osu.Game/Localisation/SettingsStrings.cs +++ b/osu.Game/Localisation/SettingsStrings.cs @@ -7,7 +7,7 @@ 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" diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs new file mode 100644 index 0000000000..8b74b94d59 --- /dev/null +++ b/osu.Game/Localisation/SkinSettingsStrings.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 SkinSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SkinSettings"; + + /// + /// "Skin" + /// + public static LocalisableString SkinSectionHeader => new TranslatableString(getKey(@"skin_section_header"), @"Skin"); + + /// + /// "Current skin" + /// + public static LocalisableString CurrentSkin => new TranslatableString(getKey(@"current_skin"), @"Current skin"); + + /// + /// "Skin layout editor" + /// + public static LocalisableString SkinLayoutEditor => new TranslatableString(getKey(@"skin_layout_editor"), @"Skin layout editor"); + + /// + /// "Gameplay cursor size" + /// + public static LocalisableString GameplayCursorSize => new TranslatableString(getKey(@"gameplay_cursor_size"), @"Gameplay cursor size"); + + /// + /// "Adjust gameplay cursor size based on current beatmap" + /// + public static LocalisableString AutoCursorSize => new TranslatableString(getKey(@"auto_cursor_size"), @"Adjust gameplay cursor size based on current beatmap"); + + /// + /// "Beatmap skins" + /// + public static LocalisableString BeatmapSkins => new TranslatableString(getKey(@"beatmap_skins"), @"Beatmap skins"); + + /// + /// "Beatmap colours" + /// + public static LocalisableString BeatmapColours => new TranslatableString(getKey(@"beatmap_colours"), @"Beatmap colours"); + + /// + /// "Beatmap hitsounds" + /// + public static LocalisableString BeatmapHitsounds => new TranslatableString(getKey(@"beatmap_hitsounds"), @"Beatmap hitsounds"); + + /// + /// "Export selected skin" + /// + public static LocalisableString ExportSkinButton => new TranslatableString(getKey(@"export_skin_button"), @"Export selected skin"); + + 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/Localisation/UserInterfaceStrings.cs b/osu.Game/Localisation/UserInterfaceStrings.cs new file mode 100644 index 0000000000..4be403edb4 --- /dev/null +++ b/osu.Game/Localisation/UserInterfaceStrings.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 osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class UserInterfaceStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.UserInterface"; + + /// + /// "User Interface" + /// + public static LocalisableString UserInterfaceSectionHeader => new TranslatableString(getKey(@"user_interface_section_header"), @"User Interface"); + + /// + /// "General" + /// + public static LocalisableString GeneralHeader => new TranslatableString(getKey(@"general_header"), @"General"); + + /// + /// "Rotate cursor when dragging" + /// + public static LocalisableString CursorRotation => new TranslatableString(getKey(@"cursor_rotation"), @"Rotate cursor when dragging"); + + /// + /// "Menu cursor size" + /// + public static LocalisableString MenuCursorSize => new TranslatableString(getKey(@"menu_cursor_size"), @"Menu cursor size"); + + /// + /// "Parallax" + /// + public static LocalisableString Parallax => new TranslatableString(getKey(@"parallax"), @"Parallax"); + + /// + /// "Hold-to-confirm activation time" + /// + public static LocalisableString HoldToConfirmActivationTime => new TranslatableString(getKey(@"hold_to_confirm_activation_time"), @"Hold-to-confirm activation time"); + + /// + /// "Main Menu" + /// + public static LocalisableString MainMenuHeader => new TranslatableString(getKey(@"main_menu_header"), @"Main Menu"); + + /// + /// "Interface voices" + /// + public static LocalisableString InterfaceVoices => new TranslatableString(getKey(@"interface_voices"), @"Interface voices"); + + /// + /// "osu! music theme" + /// + public static LocalisableString OsuMusicTheme => new TranslatableString(getKey(@"osu_music_theme"), @"osu! music theme"); + + /// + /// "Intro sequence" + /// + public static LocalisableString IntroSequence => new TranslatableString(getKey(@"intro_sequence"), @"Intro sequence"); + + /// + /// "Background source" + /// + public static LocalisableString BackgroundSource => new TranslatableString(getKey(@"background_source"), @"Background source"); + + /// + /// "Seasonal backgrounds" + /// + public static LocalisableString SeasonalBackgrounds => new TranslatableString(getKey(@"seasonal_backgrounds"), @"Seasonal backgrounds"); + + /// + /// "Changes to this setting will only apply with an active osu!supporter tag." + /// + public static LocalisableString NotSupporterNote => new TranslatableString(getKey(@"not_supporter_note"), @"Changes to this setting will only apply with an active osu!supporter tag."); + + /// + /// "Song Select" + /// + public static LocalisableString SongSelectHeader => new TranslatableString(getKey(@"song_select_header"), @"Song Select"); + + /// + /// "Right mouse drag to absolute scroll" + /// + public static LocalisableString RightMouseScroll => new TranslatableString(getKey(@"right_mouse_scroll"), @"Right mouse drag to absolute scroll"); + + /// + /// "Show converted beatmaps" + /// + public static LocalisableString ShowConvertedBeatmaps => new TranslatableString(getKey(@"show_converted_beatmaps"), @"Show converted beatmaps"); + + /// + /// "Display beatmaps from" + /// + public static LocalisableString StarsMinimum => new TranslatableString(getKey(@"stars_minimum"), @"Display beatmaps from"); + + /// + /// "up to" + /// + public static LocalisableString StarsMaximum => new TranslatableString(getKey(@"stars_maximum"), @"up to"); + + /// + /// "Random selection algorithm" + /// + public static LocalisableString RandomSelectionAlgorithm => new TranslatableString(getKey(@"random_selection_algorithm"), @"Random selection algorithm"); + + /// + /// "no limit" + /// + public static LocalisableString NoLimit => new TranslatableString(getKey(@"no_limit"), @"no limit"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs b/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs new file mode 100644 index 0000000000..afeb42130d --- /dev/null +++ b/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs @@ -0,0 +1,513 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using osu.Game.Database; + +namespace osu.Game.Migrations +{ + [DbContext(typeof(OsuDbContext))] + [Migration("20210824185035_AddCountdownSettings")] + partial class AddCountdownSettings + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("ApproachRate"); + + b.Property("CircleSize"); + + b.Property("DrainRate"); + + b.Property("OverallDifficulty"); + + b.Property("SliderMultiplier"); + + b.Property("SliderTickRate"); + + b.HasKey("ID"); + + b.ToTable("BeatmapDifficulty"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("AudioLeadIn"); + + b.Property("BPM"); + + b.Property("BaseDifficultyID"); + + b.Property("BeatDivisor"); + + b.Property("BeatmapSetInfoID"); + + b.Property("Countdown"); + + b.Property("CountdownOffset"); + + b.Property("DistanceSpacing"); + + b.Property("EpilepsyWarning"); + + b.Property("GridSize"); + + b.Property("Hash"); + + b.Property("Hidden"); + + b.Property("Length"); + + b.Property("LetterboxInBreaks"); + + b.Property("MD5Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapID"); + + b.Property("Path"); + + b.Property("RulesetID"); + + b.Property("SpecialStyle"); + + b.Property("StackLeniency"); + + b.Property("StarDifficulty"); + + b.Property("Status"); + + b.Property("StoredBookmarks"); + + b.Property("TimelineZoom"); + + b.Property("Version"); + + b.Property("WidescreenStoryboard"); + + b.HasKey("ID"); + + b.HasIndex("BaseDifficultyID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("Hash"); + + b.HasIndex("MD5Hash"); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("BeatmapInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Artist"); + + b.Property("ArtistUnicode"); + + b.Property("AudioFile"); + + b.Property("AuthorID") + .HasColumnName("AuthorID"); + + b.Property("AuthorString") + .HasColumnName("Author"); + + b.Property("BackgroundFile"); + + b.Property("PreviewTime"); + + b.Property("Source"); + + b.Property("Tags"); + + b.Property("Title"); + + b.Property("TitleUnicode"); + + b.HasKey("ID"); + + b.ToTable("BeatmapMetadata"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("BeatmapSetInfoID"); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.HasKey("ID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("FileInfoID"); + + b.ToTable("BeatmapSetFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("DateAdded"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapSetID"); + + b.Property("Protected"); + + b.Property("Status"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapSetID") + .IsUnique(); + + b.ToTable("BeatmapSetInfo"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Key") + .HasColumnName("Key"); + + b.Property("RulesetID"); + + b.Property("SkinInfoID"); + + b.Property("StringValue") + .HasColumnName("Value"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("SkinInfoID"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("osu.Game.IO.FileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Hash"); + + b.Property("ReferenceCount"); + + b.HasKey("ID"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("ReferenceCount"); + + b.ToTable("FileInfo"); + }); + + modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("IntAction") + .HasColumnName("Action"); + + b.Property("KeysString") + .HasColumnName("Keys"); + + b.Property("RulesetID"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("IntAction"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("KeyBinding"); + }); + + modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Available"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.Property("ShortName"); + + b.HasKey("ID"); + + b.HasIndex("Available"); + + b.HasIndex("ShortName") + .IsUnique(); + + b.ToTable("RulesetInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("ScoreInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("ScoreInfoID"); + + b.ToTable("ScoreFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Accuracy") + .HasColumnType("DECIMAL(1,4)"); + + b.Property("BeatmapInfoID"); + + b.Property("Combo"); + + b.Property("Date"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MaxCombo"); + + b.Property("ModsJson") + .HasColumnName("Mods"); + + b.Property("OnlineScoreID"); + + b.Property("PP"); + + b.Property("Rank"); + + b.Property("RulesetID"); + + b.Property("StatisticsJson") + .HasColumnName("Statistics"); + + b.Property("TotalScore"); + + b.Property("UserID") + .HasColumnName("UserID"); + + b.Property("UserString") + .HasColumnName("User"); + + b.HasKey("ID"); + + b.HasIndex("BeatmapInfoID"); + + b.HasIndex("OnlineScoreID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("ScoreInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("SkinInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("SkinInfoID"); + + b.ToTable("SkinFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Creator"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("SkinInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") + .WithMany() + .HasForeignKey("BaseDifficultyID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") + .WithMany("Beatmaps") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("Beatmaps") + .HasForeignKey("MetadataID"); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") + .WithMany("Files") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("BeatmapSets") + .HasForeignKey("MetadataID"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Settings") + .HasForeignKey("SkinInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Scoring.ScoreInfo") + .WithMany("Files") + .HasForeignKey("ScoreInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") + .WithMany("Scores") + .HasForeignKey("BeatmapInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Files") + .HasForeignKey("SkinInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs b/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs new file mode 100644 index 0000000000..564f5f4520 --- /dev/null +++ b/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class AddCountdownSettings : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CountdownOffset", + table: "BeatmapInfo", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CountdownOffset", + table: "BeatmapInfo"); + } + } +} diff --git a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs b/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs new file mode 100644 index 0000000000..6e53d7fae0 --- /dev/null +++ b/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs @@ -0,0 +1,515 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using osu.Game.Database; + +namespace osu.Game.Migrations +{ + [DbContext(typeof(OsuDbContext))] + [Migration("20210912144011_AddSamplesMatchPlaybackRate")] + partial class AddSamplesMatchPlaybackRate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("ApproachRate"); + + b.Property("CircleSize"); + + b.Property("DrainRate"); + + b.Property("OverallDifficulty"); + + b.Property("SliderMultiplier"); + + b.Property("SliderTickRate"); + + b.HasKey("ID"); + + b.ToTable("BeatmapDifficulty"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("AudioLeadIn"); + + b.Property("BPM"); + + b.Property("BaseDifficultyID"); + + b.Property("BeatDivisor"); + + b.Property("BeatmapSetInfoID"); + + b.Property("Countdown"); + + b.Property("CountdownOffset"); + + b.Property("DistanceSpacing"); + + b.Property("EpilepsyWarning"); + + b.Property("GridSize"); + + b.Property("Hash"); + + b.Property("Hidden"); + + b.Property("Length"); + + b.Property("LetterboxInBreaks"); + + b.Property("MD5Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapID"); + + b.Property("Path"); + + b.Property("RulesetID"); + + b.Property("SamplesMatchPlaybackRate"); + + b.Property("SpecialStyle"); + + b.Property("StackLeniency"); + + b.Property("StarDifficulty"); + + b.Property("Status"); + + b.Property("StoredBookmarks"); + + b.Property("TimelineZoom"); + + b.Property("Version"); + + b.Property("WidescreenStoryboard"); + + b.HasKey("ID"); + + b.HasIndex("BaseDifficultyID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("Hash"); + + b.HasIndex("MD5Hash"); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("BeatmapInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Artist"); + + b.Property("ArtistUnicode"); + + b.Property("AudioFile"); + + b.Property("AuthorID") + .HasColumnName("AuthorID"); + + b.Property("AuthorString") + .HasColumnName("Author"); + + b.Property("BackgroundFile"); + + b.Property("PreviewTime"); + + b.Property("Source"); + + b.Property("Tags"); + + b.Property("Title"); + + b.Property("TitleUnicode"); + + b.HasKey("ID"); + + b.ToTable("BeatmapMetadata"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("BeatmapSetInfoID"); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.HasKey("ID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("FileInfoID"); + + b.ToTable("BeatmapSetFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("DateAdded"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapSetID"); + + b.Property("Protected"); + + b.Property("Status"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapSetID") + .IsUnique(); + + b.ToTable("BeatmapSetInfo"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Key") + .HasColumnName("Key"); + + b.Property("RulesetID"); + + b.Property("SkinInfoID"); + + b.Property("StringValue") + .HasColumnName("Value"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("SkinInfoID"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("osu.Game.IO.FileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Hash"); + + b.Property("ReferenceCount"); + + b.HasKey("ID"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("ReferenceCount"); + + b.ToTable("FileInfo"); + }); + + modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("IntAction") + .HasColumnName("Action"); + + b.Property("KeysString") + .HasColumnName("Keys"); + + b.Property("RulesetID"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("IntAction"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("KeyBinding"); + }); + + modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Available"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.Property("ShortName"); + + b.HasKey("ID"); + + b.HasIndex("Available"); + + b.HasIndex("ShortName") + .IsUnique(); + + b.ToTable("RulesetInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("ScoreInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("ScoreInfoID"); + + b.ToTable("ScoreFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Accuracy") + .HasColumnType("DECIMAL(1,4)"); + + b.Property("BeatmapInfoID"); + + b.Property("Combo"); + + b.Property("Date"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MaxCombo"); + + b.Property("ModsJson") + .HasColumnName("Mods"); + + b.Property("OnlineScoreID"); + + b.Property("PP"); + + b.Property("Rank"); + + b.Property("RulesetID"); + + b.Property("StatisticsJson") + .HasColumnName("Statistics"); + + b.Property("TotalScore"); + + b.Property("UserID") + .HasColumnName("UserID"); + + b.Property("UserString") + .HasColumnName("User"); + + b.HasKey("ID"); + + b.HasIndex("BeatmapInfoID"); + + b.HasIndex("OnlineScoreID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("ScoreInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("SkinInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("SkinInfoID"); + + b.ToTable("SkinFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Creator"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("SkinInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") + .WithMany() + .HasForeignKey("BaseDifficultyID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") + .WithMany("Beatmaps") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("Beatmaps") + .HasForeignKey("MetadataID"); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") + .WithMany("Files") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("BeatmapSets") + .HasForeignKey("MetadataID"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Settings") + .HasForeignKey("SkinInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Scoring.ScoreInfo") + .WithMany("Files") + .HasForeignKey("ScoreInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") + .WithMany("Scores") + .HasForeignKey("BeatmapInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Files") + .HasForeignKey("SkinInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs b/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs new file mode 100644 index 0000000000..bf3f855d5f --- /dev/null +++ b/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class AddSamplesMatchPlaybackRate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SamplesMatchPlaybackRate", + table: "BeatmapInfo", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SamplesMatchPlaybackRate", + table: "BeatmapInfo"); + } + } +} diff --git a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs index f518cfb42b..036c26cb0a 100644 --- a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs +++ b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs @@ -53,7 +53,9 @@ namespace osu.Game.Migrations b.Property("BeatmapSetInfoID"); - b.Property("Countdown"); + b.Property("Countdown"); + + b.Property("CountdownOffset"); b.Property("DistanceSpacing"); @@ -79,6 +81,8 @@ namespace osu.Game.Migrations b.Property("RulesetID"); + b.Property("SamplesMatchPlaybackRate"); + b.Property("SpecialStyle"); b.Property("StackLeniency"); diff --git a/osu.Game/Models/RealmBeatmap.cs b/osu.Game/Models/RealmBeatmap.cs new file mode 100644 index 0000000000..5049c1384d --- /dev/null +++ b/osu.Game/Models/RealmBeatmap.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using Newtonsoft.Json; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + /// + /// A single beatmap difficulty. + /// + [ExcludeFromDynamicCompile] + [Serializable] + [MapTo("Beatmap")] + public class RealmBeatmap : RealmObject, IHasGuidPrimaryKey, IBeatmapInfo + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + public string DifficultyName { get; set; } = string.Empty; + + public RealmRuleset Ruleset { get; set; } = null!; + + public RealmBeatmapDifficulty Difficulty { get; set; } = null!; + + public RealmBeatmapMetadata Metadata { get; set; } = null!; + + public RealmBeatmapSet? BeatmapSet { get; set; } + + public BeatmapSetOnlineStatus Status + { + get => (BeatmapSetOnlineStatus)StatusInt; + set => StatusInt = (int)value; + } + + [MapTo(nameof(Status))] + public int StatusInt { get; set; } + + public int? OnlineID { get; set; } + + public double Length { get; set; } + + public double BPM { get; set; } + + public string Hash { get; set; } = string.Empty; + + public double StarRating { get; set; } + + public string MD5Hash { get; set; } = string.Empty; + + [JsonIgnore] + public bool Hidden { get; set; } + + public RealmBeatmap(RealmRuleset ruleset, RealmBeatmapDifficulty difficulty, RealmBeatmapMetadata metadata) + { + Ruleset = ruleset; + Difficulty = difficulty; + Metadata = metadata; + } + + [UsedImplicitly] + private RealmBeatmap() + { + } + + #region Properties we may not want persisted (but also maybe no harm?) + + public double AudioLeadIn { get; set; } + + public float StackLeniency { get; set; } = 0.7f; + + public bool SpecialStyle { get; set; } + + public bool LetterboxInBreaks { get; set; } + + public bool WidescreenStoryboard { get; set; } + + public bool EpilepsyWarning { get; set; } + + public bool SamplesMatchPlaybackRate { get; set; } + + public double DistanceSpacing { get; set; } + + public int BeatDivisor { get; set; } + + public int GridSize { get; set; } + + public double TimelineZoom { get; set; } + + #endregion + + public bool AudioEquals(RealmBeatmap? other) => other != null + && BeatmapSet != null + && other.BeatmapSet != null + && BeatmapSet.Hash == other.BeatmapSet.Hash + && Metadata.AudioFile == other.Metadata.AudioFile; + + public bool BackgroundEquals(RealmBeatmap? other) => other != null + && BeatmapSet != null + && other.BeatmapSet != null + && BeatmapSet.Hash == other.BeatmapSet.Hash + && Metadata.BackgroundFile == other.Metadata.BackgroundFile; + + IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; + IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; + IRulesetInfo IBeatmapInfo.Ruleset => Ruleset; + IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => Difficulty; + } +} diff --git a/osu.Game/Models/RealmBeatmapDifficulty.cs b/osu.Game/Models/RealmBeatmapDifficulty.cs new file mode 100644 index 0000000000..3c1dad69e4 --- /dev/null +++ b/osu.Game/Models/RealmBeatmapDifficulty.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("BeatmapDifficulty")] + public class RealmBeatmapDifficulty : EmbeddedObject, IBeatmapDifficultyInfo + { + public float DrainRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float CircleSize { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float OverallDifficulty { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float ApproachRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + + public double SliderMultiplier { get; set; } = 1; + public double SliderTickRate { get; set; } = 1; + + /// + /// Returns a shallow-clone of this . + /// + public RealmBeatmapDifficulty Clone() + { + var diff = new RealmBeatmapDifficulty(); + CopyTo(diff); + return diff; + } + + public void CopyTo(RealmBeatmapDifficulty difficulty) + { + difficulty.ApproachRate = ApproachRate; + difficulty.DrainRate = DrainRate; + difficulty.CircleSize = CircleSize; + difficulty.OverallDifficulty = OverallDifficulty; + + difficulty.SliderMultiplier = SliderMultiplier; + difficulty.SliderTickRate = SliderTickRate; + } + } +} diff --git a/osu.Game/Models/RealmBeatmapMetadata.cs b/osu.Game/Models/RealmBeatmapMetadata.cs new file mode 100644 index 0000000000..6ea7170d0f --- /dev/null +++ b/osu.Game/Models/RealmBeatmapMetadata.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [Serializable] + [MapTo("BeatmapMetadata")] + public class RealmBeatmapMetadata : RealmObject, IBeatmapMetadataInfo + { + public string Title { get; set; } = string.Empty; + + [JsonProperty("title_unicode")] + public string TitleUnicode { get; set; } = string.Empty; + + public string Artist { get; set; } = string.Empty; + + [JsonProperty("artist_unicode")] + public string ArtistUnicode { get; set; } = string.Empty; + + public string Author { get; set; } = string.Empty; // eventually should be linked to a persisted User. + + public string Source { get; set; } = string.Empty; + + [JsonProperty(@"tags")] + public string Tags { get; set; } = string.Empty; + + /// + /// The time in milliseconds to begin playing the track for preview purposes. + /// If -1, the track should begin playing at 40% of its length. + /// + public int PreviewTime { get; set; } + + public string AudioFile { get; set; } = string.Empty; + public string BackgroundFile { get; set; } = string.Empty; + } +} diff --git a/osu.Game/Models/RealmBeatmapSet.cs b/osu.Game/Models/RealmBeatmapSet.cs new file mode 100644 index 0000000000..314ca4494b --- /dev/null +++ b/osu.Game/Models/RealmBeatmapSet.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 System.Linq; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("BeatmapSet")] + public class RealmBeatmapSet : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable, IBeatmapSetInfo + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + public int? OnlineID { get; set; } + + public DateTimeOffset DateAdded { get; set; } + + public IBeatmapMetadataInfo? Metadata => Beatmaps.FirstOrDefault()?.Metadata; + + public IList Beatmaps { get; } = null!; + + public IList Files { get; } = null!; + + public bool DeletePending { get; set; } + + public string Hash { get; set; } = string.Empty; + + /// + /// Whether deleting this beatmap set should be prohibited (due to it being a system requirement to be present). + /// + public bool Protected { get; set; } + + public double MaxStarDifficulty => Beatmaps.Max(b => b.StarRating); + + public double MaxLength => Beatmaps.Max(b => b.Length); + + public double MaxBPM => Beatmaps.Max(b => b.BPM); + + /// + /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// + /// The name of the file to get the storage path of. + public string? GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.StoragePath; + + public override string ToString() => Metadata?.ToString() ?? base.ToString(); + + public bool Equals(RealmBeatmapSet? other) + { + if (other == null) + return false; + + if (IsManaged && other.IsManaged) + return ID == other.ID; + + if (OnlineID.HasValue && other.OnlineID.HasValue) + return OnlineID == other.OnlineID; + + if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) + return Hash == other.Hash; + + return ReferenceEquals(this, other); + } + + IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; + + IEnumerable IBeatmapSetInfo.Files => Files; + } +} diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs new file mode 100644 index 0000000000..2715f4be45 --- /dev/null +++ b/osu.Game/Models/RealmFile.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.IO; +using osu.Framework.Testing; +using osu.Game.IO; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("File")] + public class RealmFile : RealmObject, IFileInfo + { + [PrimaryKey] + public string Hash { get; set; } = string.Empty; + + public string StoragePath => Path.Combine(Hash.Remove(1), Hash.Remove(2), Hash); + } +} diff --git a/osu.Game/Models/RealmNamedFileUsage.cs b/osu.Game/Models/RealmNamedFileUsage.cs new file mode 100644 index 0000000000..ba12d51d0b --- /dev/null +++ b/osu.Game/Models/RealmNamedFileUsage.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 JetBrains.Annotations; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.IO; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + public class RealmNamedFileUsage : EmbeddedObject, INamedFile, INamedFileUsage + { + public RealmFile File { get; set; } = null!; + + public string Filename { get; set; } = null!; + + public RealmNamedFileUsage(RealmFile file, string filename) + { + File = file; + Filename = filename; + } + + [UsedImplicitly] + private RealmNamedFileUsage() + { + } + + IFileInfo INamedFileUsage.File => File; + } +} diff --git a/osu.Game/Models/RealmRuleset.cs b/osu.Game/Models/RealmRuleset.cs new file mode 100644 index 0000000000..0dcd701ed2 --- /dev/null +++ b/osu.Game/Models/RealmRuleset.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 JetBrains.Annotations; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("Ruleset")] + public class RealmRuleset : RealmObject, IEquatable, IRulesetInfo + { + [PrimaryKey] + public string ShortName { get; set; } = string.Empty; + + public int? OnlineID { get; set; } + + public string Name { get; set; } = string.Empty; + + public string InstantiationInfo { get; set; } = string.Empty; + + public RealmRuleset(string shortName, string name, string instantiationInfo, int? onlineID = null) + { + ShortName = shortName; + Name = name; + InstantiationInfo = instantiationInfo; + OnlineID = onlineID; + } + + [UsedImplicitly] + private RealmRuleset() + { + } + + public RealmRuleset(int? onlineID, string name, string shortName, bool available) + { + OnlineID = onlineID; + Name = name; + ShortName = shortName; + Available = available; + } + + public bool Available { get; set; } + + public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo; + + public override string ToString() => Name; + + public RealmRuleset Clone() => new RealmRuleset + { + OnlineID = OnlineID, + Name = Name, + ShortName = ShortName, + InstantiationInfo = InstantiationInfo, + Available = Available + }; + } +} diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 1686595512..94508e3a81 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -35,9 +35,8 @@ namespace osu.Game.Online.API public string WebsiteRootUrl { get; } - /// - /// The username/email provided by the user when initiating a login. - /// + public Exception LastLoginError { get; private set; } + public string ProvidedUsername { get; private set; } private string password; @@ -136,18 +135,37 @@ namespace osu.Game.Online.API // save the username at this point, if the user requested for it to be. config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); - if (!authentication.HasValidAccessToken && !authentication.AuthenticateWithLogin(ProvidedUsername, password)) + if (!authentication.HasValidAccessToken) { - //todo: this fails even on network-related issues. we should probably handle those differently. - //NotificationOverlay.ShowMessage("Login failed!"); - log.Add(@"Login failed!"); - password = null; - authentication.Clear(); - continue; + LastLoginError = null; + + try + { + authentication.AuthenticateWithLogin(ProvidedUsername, password); + } + catch (Exception e) + { + //todo: this fails even on network-related issues. we should probably handle those differently. + LastLoginError = e; + log.Add(@"Login failed!"); + password = null; + authentication.Clear(); + continue; + } } var userReq = new GetUserRequest(); + userReq.Failure += ex => + { + if (ex is WebException webException && webException.Message == @"Unauthorized") + { + log.Add(@"Login no longer valid"); + Logout(); + } + else + failConnectionProcess(); + }; userReq.Success += u => { localUser.Value = u; @@ -167,6 +185,7 @@ namespace osu.Game.Online.API // getting user's friends is considered part of the connection process. var friendsReq = new GetFriendsRequest(); + friendsReq.Failure += _ => failConnectionProcess(); friendsReq.Success += res => { friends.AddRange(res); @@ -246,8 +265,8 @@ namespace osu.Game.Online.API this.password = password; } - public IHubClientConnector GetHubConnector(string clientName, string endpoint) => - new HubClientConnector(clientName, endpoint, this, versionHash); + public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => + new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack); public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { @@ -294,9 +313,11 @@ namespace osu.Game.Online.API { req.Perform(this); + if (req.CompletionState != APIRequestCompletionState.Completed) + return false; + // we could still be in initialisation, at which point we don't want to say we're Online yet. if (IsLoggedIn) state.Value = APIState.Online; - failureCount = 0; return true; } @@ -370,7 +391,7 @@ namespace osu.Game.Online.API } } - public bool IsLoggedIn => localUser.Value.Id > 1; + public bool IsLoggedIn => localUser.Value.Id > 1; // TODO: should this also be true if attempting to connect? public void Queue(APIRequest request) { diff --git a/osu.Game/Online/API/APIDownloadRequest.cs b/osu.Game/Online/API/APIDownloadRequest.cs index 62e22d8f88..63bb3e2287 100644 --- a/osu.Game/Online/API/APIDownloadRequest.cs +++ b/osu.Game/Online/API/APIDownloadRequest.cs @@ -16,6 +16,11 @@ namespace osu.Game.Online.API /// protected virtual string FileExtension { get; } = @".tmp"; + protected APIDownloadRequest() + { + base.Success += () => Success?.Invoke(filename); + } + protected override WebRequest CreateWebRequest() { var file = Path.GetTempFileName(); @@ -39,12 +44,6 @@ namespace osu.Game.Online.API TriggerSuccess(); } - internal override void TriggerSuccess() - { - base.TriggerSuccess(); - Success?.Invoke(filename); - } - public event APIProgressHandler Progressed; public new event APISuccessHandler Success; diff --git a/osu.Game/Online/API/APIException.cs b/osu.Game/Online/API/APIException.cs new file mode 100644 index 0000000000..97786bced9 --- /dev/null +++ b/osu.Game/Online/API/APIException.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Online.API +{ + public class APIException : InvalidOperationException + { + public APIException(string messsage, Exception innerException) + : base(messsage, innerException) + { + } + } +} diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs index 4427c82a8b..62f9976c0f 100644 --- a/osu.Game/Online/API/APIMod.cs +++ b/osu.Game/Online/API/APIMod.cs @@ -16,7 +16,7 @@ using osu.Game.Utils; namespace osu.Game.Online.API { [MessagePackObject] - public class APIMod : IMod, IEquatable + public class APIMod : IEquatable { [JsonProperty("acronym")] [Key(0)] @@ -48,31 +48,31 @@ namespace osu.Game.Online.API public Mod ToMod(Ruleset ruleset) { - Mod resultMod = ruleset.GetAllMods().FirstOrDefault(m => m.Acronym == Acronym); + Mod resultMod = ruleset.CreateModFromAcronym(Acronym); if (resultMod == null) throw new InvalidOperationException($"There is no mod in the ruleset ({ruleset.ShortName}) matching the acronym {Acronym}."); - foreach (var (_, property) in resultMod.GetSettingsSourceProperties()) + if (Settings.Count > 0) { - if (!Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) - continue; + foreach (var (_, property) in resultMod.GetSettingsSourceProperties()) + { + if (!Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) + continue; - resultMod.CopyAdjustedSetting((IBindable)property.GetValue(resultMod), settingValue); + resultMod.CopyAdjustedSetting((IBindable)property.GetValue(resultMod), settingValue); + } } return resultMod; } - public bool Equals(IMod other) => other is APIMod them && Equals(them); - public bool Equals(APIMod other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; - return Acronym == other.Acronym && - Settings.SequenceEqual(other.Settings, ModSettingsEqualityComparer.Default); + return Acronym == other.Acronym && Settings.SequenceEqual(other.Settings, ModSettingsEqualityComparer.Default); } public override string ToString() diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 1a6868cfa4..d60c9cfe65 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.IO.Network; using osu.Framework.Logging; @@ -17,7 +18,11 @@ namespace osu.Game.Online.API { protected override WebRequest CreateWebRequest() => new OsuJsonWebRequest(Uri); - public T Result { get; private set; } + /// + /// The deserialised response object. May be null if the request or deserialisation failed. + /// + [CanBeNull] + public T Response { get; private set; } /// /// Invoked on successful completion of an API request. @@ -25,27 +30,26 @@ namespace osu.Game.Online.API /// public new event APISuccessHandler Success; + protected APIRequest() + { + base.Success += () => Success?.Invoke(Response); + } + protected override void PostProcess() { base.PostProcess(); - Result = ((OsuJsonWebRequest)WebRequest)?.ResponseObject; + Response = ((OsuJsonWebRequest)WebRequest)?.ResponseObject; } internal void TriggerSuccess(T result) { - if (Result != null) + if (Response != null) throw new InvalidOperationException("Attempted to trigger success more than once"); - Result = result; + Response = result; TriggerSuccess(); } - - internal override void TriggerSuccess() - { - base.TriggerSuccess(); - Success?.Invoke(Result); - } } /// @@ -79,9 +83,13 @@ namespace osu.Game.Online.API /// public event APIFailureHandler Failure; - private bool cancelled; + private readonly object completionStateLock = new object(); - private Action pendingFailure; + /// + /// The state of this request, from an outside perspective. + /// This is used to ensure correct notification events are fired. + /// + public APIRequestCompletionState CompletionState { get; private set; } public void Perform(IAPIProvider api) { @@ -94,34 +102,23 @@ namespace osu.Game.Online.API API = apiAccess; User = apiAccess.LocalUser.Value; - if (checkAndScheduleFailure()) - return; + if (isFailing) return; WebRequest = CreateWebRequest(); WebRequest.Failed += Fail; WebRequest.AllowRetryOnTimeout = false; WebRequest.AddHeader("Authorization", $"Bearer {API.AccessToken}"); - if (checkAndScheduleFailure()) - return; + if (isFailing) return; - if (!WebRequest.Aborted) // could have been aborted by a Cancel() call - { - Logger.Log($@"Performing request {this}", LoggingTarget.Network); - WebRequest.Perform(); - } + Logger.Log($@"Performing request {this}", LoggingTarget.Network); + WebRequest.Perform(); - if (checkAndScheduleFailure()) - return; + if (isFailing) return; PostProcess(); - API.Schedule(delegate - { - if (cancelled) return; - - TriggerSuccess(); - }); + TriggerSuccess(); } /// @@ -131,65 +128,85 @@ namespace osu.Game.Online.API { } - private bool succeeded; - - internal virtual void TriggerSuccess() + internal void TriggerSuccess() { - succeeded = true; - Success?.Invoke(); + lock (completionStateLock) + { + if (CompletionState != APIRequestCompletionState.Waiting) + return; + + CompletionState = APIRequestCompletionState.Completed; + } + + if (API == null) + Success?.Invoke(); + else + API.Schedule(() => Success?.Invoke()); } internal void TriggerFailure(Exception e) { - Failure?.Invoke(e); + lock (completionStateLock) + { + if (CompletionState != APIRequestCompletionState.Waiting) + return; + + CompletionState = APIRequestCompletionState.Failed; + } + + if (API == null) + Failure?.Invoke(e); + else + API.Schedule(() => Failure?.Invoke(e)); } public void Cancel() => Fail(new OperationCanceledException(@"Request cancelled")); public void Fail(Exception e) { - if (succeeded || cancelled) - return; - - cancelled = true; - WebRequest?.Abort(); - - string responseString = WebRequest?.GetResponseString(); - - if (!string.IsNullOrEmpty(responseString)) + lock (completionStateLock) { - try - { - // attempt to decode a displayable error string. - var error = JsonConvert.DeserializeObject(responseString); - if (error != null) - e = new APIException(error.ErrorMessage, e); - } - catch - { - } - } + if (CompletionState != APIRequestCompletionState.Waiting) + return; - Logger.Log($@"Failing request {this} ({e})", LoggingTarget.Network); - pendingFailure = () => TriggerFailure(e); - checkAndScheduleFailure(); + WebRequest?.Abort(); + + // in the case of a cancellation we don't care about whether there's an error in the response. + if (!(e is OperationCanceledException)) + { + string responseString = WebRequest?.GetResponseString(); + + // naive check whether there's an error in the response to avoid unnecessary JSON deserialisation. + if (!string.IsNullOrEmpty(responseString) && responseString.Contains(@"""error""")) + { + try + { + // attempt to decode a displayable error string. + var error = JsonConvert.DeserializeObject(responseString); + if (error != null) + e = new APIException(error.ErrorMessage, e); + } + catch + { + } + } + } + + Logger.Log($@"Failing request {this} ({e})", LoggingTarget.Network); + TriggerFailure(e); + } } /// - /// Checked for cancellation or error. Also queues up the Failed event if we can. + /// Whether this request is in a failing or failed state. /// - /// Whether we are in a failed or cancelled state. - private bool checkAndScheduleFailure() + private bool isFailing { - if (pendingFailure == null) return cancelled; - - if (API == null) - pendingFailure(); - else - API.Schedule(pendingFailure); - - pendingFailure = null; - return true; + get + { + lock (completionStateLock) + return CompletionState == APIRequestCompletionState.Failed; + } } private class DisplayableError @@ -199,14 +216,6 @@ namespace osu.Game.Online.API } } - public class APIException : InvalidOperationException - { - public APIException(string messsage, Exception innerException) - : base(messsage, innerException) - { - } - } - public delegate void APIFailureHandler(Exception e); public delegate void APISuccessHandler(); diff --git a/osu.Game/Online/API/APIRequestCompletionState.cs b/osu.Game/Online/API/APIRequestCompletionState.cs new file mode 100644 index 0000000000..84c9974dd8 --- /dev/null +++ b/osu.Game/Online/API/APIRequestCompletionState.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.API +{ + public enum APIRequestCompletionState + { + /// + /// Not yet run or currently waiting on response. + /// + Waiting, + + /// + /// Ran to completion. + /// + Completed, + + /// + /// Cancelled or failed due to error. + /// + Failed + } +} diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 52f2365165..8f91a4d198 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -32,6 +32,8 @@ namespace osu.Game.Online.API public string WebsiteRootUrl => "http://localhost"; + public Exception LastLoginError { get; private set; } + /// /// Provide handling logic for an arbitrary API request. /// Should return true is a request was handled. If null or false return, the request will be failed with a . @@ -40,6 +42,8 @@ namespace osu.Game.Online.API private readonly Bindable state = new Bindable(APIState.Online); + private bool shouldFailNextLogin; + /// /// The current connectivity state of the API. /// @@ -74,6 +78,18 @@ namespace osu.Game.Online.API public void Login(string username, string password) { + state.Value = APIState.Connecting; + + if (shouldFailNextLogin) + { + LastLoginError = new APIException("Not powerful enough to login.", new ArgumentException(nameof(shouldFailNextLogin))); + + state.Value = APIState.Offline; + shouldFailNextLogin = false; + return; + } + + LastLoginError = null; LocalUser.Value = new User { Username = username, @@ -89,7 +105,7 @@ namespace osu.Game.Online.API state.Value = APIState.Offline; } - public IHubClientConnector GetHubConnector(string clientName, string endpoint) => null; + public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { @@ -102,5 +118,7 @@ namespace osu.Game.Online.API IBindable IAPIProvider.LocalUser => LocalUser; IBindableList IAPIProvider.Friends => Friends; IBindable IAPIProvider.Activity => Activity; + + public void FailNextLogin() => shouldFailNextLogin = true; } } diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 3a77b9cfee..72ca37bcf4 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -3,6 +3,7 @@ #nullable enable +using System; using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Game.Users; @@ -55,6 +56,11 @@ namespace osu.Game.Online.API /// string WebsiteRootUrl { get; } + /// + /// The last login error that occurred, if any. + /// + Exception? LastLoginError { get; } + /// /// The current connection state of the API. /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. @@ -102,7 +108,8 @@ namespace osu.Game.Online.API /// /// The name of the client this connector connects for, used for logging. /// The endpoint to the hub. - IHubClientConnector? GetHubConnector(string clientName, string endpoint); + /// Whether to use MessagePack for serialisation if available on this platform. + IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true); /// /// Create a new user account. This is a blocking operation. diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs index bdc47aab8d..d79fc58d1c 100644 --- a/osu.Game/Online/API/OAuth.cs +++ b/osu.Game/Online/API/OAuth.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 System.Diagnostics; using System.Net.Http; +using Newtonsoft.Json; using osu.Framework.Bindables; namespace osu.Game.Online.API @@ -32,10 +34,10 @@ namespace osu.Game.Online.API this.endpoint = endpoint; } - internal bool AuthenticateWithLogin(string username, string password) + internal void AuthenticateWithLogin(string username, string password) { - if (string.IsNullOrEmpty(username)) return false; - if (string.IsNullOrEmpty(password)) return false; + if (string.IsNullOrEmpty(username)) throw new ArgumentException("Missing username."); + if (string.IsNullOrEmpty(password)) throw new ArgumentException("Missing password."); using (var req = new AccessTokenRequestPassword(username, password) { @@ -49,13 +51,27 @@ namespace osu.Game.Online.API { req.Perform(); } - catch + catch (Exception ex) { - return false; + Token.Value = null; + + var throwableException = ex; + + try + { + // attempt to decode a displayable error string. + var error = JsonConvert.DeserializeObject(req.GetResponseString() ?? string.Empty); + if (error != null) + throwableException = new APIException(error.UserDisplayableError, ex); + } + catch + { + } + + throw throwableException; } Token.Value = req.ResponseObject; - return true; } } @@ -182,5 +198,19 @@ namespace osu.Game.Online.API base.PrePerform(); } } + + private class OAuthError + { + public string UserDisplayableError => !string.IsNullOrEmpty(Hint) ? Hint : ErrorIdentifier; + + [JsonProperty("error")] + public string ErrorIdentifier { get; set; } + + [JsonProperty("hint")] + public string Hint { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + } } } diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index 87925b94c6..901f7365b8 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -8,13 +8,13 @@ namespace osu.Game.Online.API.Requests { public class GetBeatmapRequest : APIRequest { - private readonly BeatmapInfo beatmap; + private readonly BeatmapInfo beatmapInfo; - public GetBeatmapRequest(BeatmapInfo beatmap) + public GetBeatmapRequest(BeatmapInfo beatmapInfo) { - this.beatmap = beatmap; + this.beatmapInfo = beatmapInfo; } - protected override string Target => $@"beatmaps/lookup?id={beatmap.OnlineBeatmapID}&checksum={beatmap.MD5Hash}&filename={System.Uri.EscapeUriString(beatmap.Path ?? string.Empty)}"; + protected override string Target => $@"beatmaps/lookup?id={beatmapInfo.OnlineBeatmapID}&checksum={beatmapInfo.MD5Hash}&filename={System.Uri.EscapeUriString(beatmapInfo.Path ?? string.Empty)}"; } } diff --git a/osu.Game/Online/API/Requests/GetMessagesRequest.cs b/osu.Game/Online/API/Requests/GetMessagesRequest.cs index 36e81a9348..651f8a06c5 100644 --- a/osu.Game/Online/API/Requests/GetMessagesRequest.cs +++ b/osu.Game/Online/API/Requests/GetMessagesRequest.cs @@ -8,13 +8,13 @@ namespace osu.Game.Online.API.Requests { public class GetMessagesRequest : APIRequest> { - private readonly Channel channel; + public readonly Channel Channel; public GetMessagesRequest(Channel channel) { - this.channel = channel; + Channel = channel; } - protected override string Target => $@"chat/channels/{channel.Id}/messages"; + protected override string Target => $@"chat/channels/{Channel.Id}/messages"; } } diff --git a/osu.Game/Online/API/Requests/GetScoresRequest.cs b/osu.Game/Online/API/Requests/GetScoresRequest.cs index bf3441d2a0..f3bf690ed5 100644 --- a/osu.Game/Online/API/Requests/GetScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetScoresRequest.cs @@ -15,23 +15,23 @@ namespace osu.Game.Online.API.Requests { public class GetScoresRequest : APIRequest { - private readonly BeatmapInfo beatmap; + private readonly BeatmapInfo beatmapInfo; private readonly BeatmapLeaderboardScope scope; private readonly RulesetInfo ruleset; - private readonly IEnumerable mods; + private readonly IEnumerable mods; - public GetScoresRequest(BeatmapInfo beatmap, RulesetInfo ruleset, BeatmapLeaderboardScope scope = BeatmapLeaderboardScope.Global, IEnumerable mods = null) + public GetScoresRequest(BeatmapInfo beatmapInfo, RulesetInfo ruleset, BeatmapLeaderboardScope scope = BeatmapLeaderboardScope.Global, IEnumerable mods = null) { - if (!beatmap.OnlineBeatmapID.HasValue) + if (!beatmapInfo.OnlineBeatmapID.HasValue) throw new InvalidOperationException($"Cannot lookup a beatmap's scores without having a populated {nameof(BeatmapInfo.OnlineBeatmapID)}."); if (scope == BeatmapLeaderboardScope.Local) throw new InvalidOperationException("Should not attempt to request online scores for a local scoped leaderboard"); - this.beatmap = beatmap; + this.beatmapInfo = beatmapInfo; this.scope = scope; this.ruleset = ruleset ?? throw new ArgumentNullException(nameof(ruleset)); - this.mods = mods ?? Array.Empty(); + this.mods = mods ?? Array.Empty(); Success += onSuccess; } @@ -42,7 +42,7 @@ namespace osu.Game.Online.API.Requests foreach (APILegacyScoreInfo score in r.Scores) { - score.Beatmap = beatmap; + score.BeatmapInfo = beatmapInfo; score.OnlineRulesetID = ruleset.ID.Value; } @@ -50,12 +50,12 @@ namespace osu.Game.Online.API.Requests if (userScore != null) { - userScore.Score.Beatmap = beatmap; + userScore.Score.BeatmapInfo = beatmapInfo; userScore.Score.OnlineRulesetID = ruleset.ID.Value; } } - protected override string Target => $@"beatmaps/{beatmap.OnlineBeatmapID}/scores{createQueryParameters()}"; + protected override string Target => $@"beatmaps/{beatmapInfo.OnlineBeatmapID}/scores{createQueryParameters()}"; private string createQueryParameters() { 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/GetUserBeatmapsRequest.cs b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs index fb1385793f..e2c0ed4301 100644 --- a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs @@ -26,9 +26,9 @@ namespace osu.Game.Online.API.Requests public enum BeatmapSetType { Favourite, - RankedAndApproved, + Ranked, Loved, - Unranked, + Pending, Graveyard } } diff --git a/osu.Game/Online/API/Requests/GetUserRequest.cs b/osu.Game/Online/API/Requests/GetUserRequest.cs index 42aad6f9eb..730e4e02ed 100644 --- a/osu.Game/Online/API/Requests/GetUserRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRequest.cs @@ -8,15 +8,47 @@ namespace osu.Game.Online.API.Requests { public class GetUserRequest : APIRequest { - private readonly long? userId; + public readonly string Lookup; public readonly RulesetInfo Ruleset; + private readonly LookupType lookupType; + /// + /// Gets the currently logged-in user. + /// + public GetUserRequest() + { + } + + /// + /// Gets a user from their ID. + /// + /// The user to get. + /// The ruleset to get the user's info for. public GetUserRequest(long? userId = null, RulesetInfo ruleset = null) { - this.userId = userId; + Lookup = userId.ToString(); + lookupType = LookupType.Id; Ruleset = ruleset; } - protected override string Target => userId.HasValue ? $@"users/{userId}/{Ruleset?.ShortName}" : $@"me/{Ruleset?.ShortName}"; + /// + /// Gets a user from their username. + /// + /// The user to get. + /// The ruleset to get the user's info for. + public GetUserRequest(string username = null, RulesetInfo ruleset = null) + { + Lookup = username; + lookupType = LookupType.Username; + Ruleset = ruleset; + } + + protected override string Target => Lookup != null ? $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLower()}" : $@"me/{Ruleset?.ShortName}"; + + private enum LookupType + { + Id, + Username + } } } diff --git a/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs b/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs index 95a5d0acbd..b24669e6d5 100644 --- a/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs +++ b/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs @@ -9,16 +9,16 @@ namespace osu.Game.Online.API.Requests { public class MarkChannelAsReadRequest : APIRequest { - private readonly Channel channel; - private readonly Message message; + public readonly Channel Channel; + public readonly Message Message; public MarkChannelAsReadRequest(Channel channel, Message message) { - this.channel = channel; - this.message = message; + Channel = channel; + Message = message; } - protected override string Target => $"chat/channels/{channel.Id}/mark-as-read/{message.Id}"; + protected override string Target => $"chat/channels/{Channel.Id}/mark-as-read/{Message.Id}"; protected override WebRequest CreateWebRequest() { diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index 7343870dbc..c2a68c8ca1 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -64,7 +64,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"max_combo")] private int? maxCombo { get; set; } - public virtual BeatmapInfo ToBeatmap(RulesetStore rulesets) + public virtual BeatmapInfo ToBeatmapInfo(RulesetStore rulesets) { var set = BeatmapSet?.ToBeatmapSet(rulesets); diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index 45d9c9405f..35963792d0 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -63,6 +63,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"ratings")] private int[] ratings { get; set; } + [JsonProperty(@"track_id")] + private int? trackId { get; set; } + [JsonProperty(@"user_id")] private int creatorId { @@ -106,13 +109,14 @@ namespace osu.Game.Online.API.Requests.Responses Availability = availability, HasFavourited = hasFavourited, Genre = genre, - Language = language + Language = language, + TrackId = trackId }, }; beatmapSet.Beatmaps = beatmaps?.Select(b => { - var beatmap = b.ToBeatmap(rulesets); + var beatmap = b.ToBeatmapInfo(rulesets); beatmap.BeatmapSet = beatmapSet; beatmap.Metadata = beatmapSet.Metadata; return beatmap; diff --git a/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs index 1b394185fd..aaf2dccc82 100644 --- a/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs @@ -23,10 +23,10 @@ namespace osu.Game.Online.API.Requests.Responses var rulesetInstance = ruleset.CreateInstance(); - var mods = Mods != null ? rulesetInstance.GetAllMods().Where(mod => Mods.Contains(mod.Acronym)).ToArray() : Array.Empty(); + var mods = Mods != null ? Mods.Select(acronym => rulesetInstance.CreateModFromAcronym(acronym)).Where(m => m != null).ToArray() : Array.Empty(); // all API scores provided by this class are considered to be legacy. - mods = mods.Append(rulesetInstance.GetAllMods().OfType().Single()).ToArray(); + mods = mods.Append(rulesetInstance.CreateMod()).ToArray(); var scoreInfo = new ScoreInfo { @@ -37,7 +37,7 @@ namespace osu.Game.Online.API.Requests.Responses OnlineScoreID = OnlineScoreID, Date = Date, PP = PP, - Beatmap = Beatmap, + BeatmapInfo = BeatmapInfo, RulesetID = OnlineRulesetID, Hash = Replay ? "online" : string.Empty, // todo: temporary? Rank = Rank, @@ -100,7 +100,7 @@ namespace osu.Game.Online.API.Requests.Responses public DateTimeOffset Date { get; set; } [JsonProperty(@"beatmap")] - public BeatmapInfo Beatmap { get; set; } + public BeatmapInfo BeatmapInfo { get; set; } [JsonProperty("accuracy")] public double Accuracy { get; set; } @@ -114,10 +114,10 @@ namespace osu.Game.Online.API.Requests.Responses set { // extract the set ID to its correct place. - Beatmap.BeatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = value.ID }; + BeatmapInfo.BeatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = value.ID }; value.ID = 0; - Beatmap.Metadata = value; + BeatmapInfo.Metadata = value; } } diff --git a/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs index 4614fe29b7..10f7ca6fe2 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs @@ -15,8 +15,8 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("count")] public int PlayCount { get; set; } - [JsonProperty] - private BeatmapInfo beatmap { get; set; } + [JsonProperty("beatmap")] + private BeatmapInfo beatmapInfo { get; set; } [JsonProperty] private APIBeatmapSet beatmapSet { get; set; } @@ -24,9 +24,9 @@ namespace osu.Game.Online.API.Requests.Responses public BeatmapInfo GetBeatmapInfo(RulesetStore rulesets) { BeatmapSetInfo setInfo = beatmapSet.ToBeatmapSet(rulesets); - beatmap.BeatmapSet = setInfo; - beatmap.Metadata = setInfo.Metadata; - return beatmap; + beatmapInfo.BeatmapSet = setInfo; + beatmapInfo.Metadata = setInfo.Metadata; + return beatmapInfo; } } } diff --git a/osu.Game/Online/API/Requests/Responses/Comment.cs b/osu.Game/Online/API/Requests/Responses/Comment.cs index 05a24cec0e..32d489432d 100644 --- a/osu.Game/Online/API/Requests/Responses/Comment.cs +++ b/osu.Game/Online/API/Requests/Responses/Comment.cs @@ -58,6 +58,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"edited_by_id")] public long? EditedById { get; set; } + [JsonProperty(@"pinned")] + public bool Pinned { get; set; } + public User EditedUser { get; set; } public bool IsTopLevel => !ParentId.HasValue; diff --git a/osu.Game/Online/API/Requests/Responses/CommentBundle.cs b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs index d76ede67cd..4070df493d 100644 --- a/osu.Game/Online/API/Requests/Responses/CommentBundle.cs +++ b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using osu.Game.Users; using System.Collections.Generic; +using System.Linq; namespace osu.Game.Online.API.Requests.Responses { @@ -24,6 +25,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"included_comments")] public List IncludedComments { get; set; } + [JsonProperty(@"pinned_comments")] + public List PinnedComments { get; set; } + private List userVotes; [JsonProperty(@"user_votes")] @@ -49,26 +53,17 @@ namespace osu.Game.Online.API.Requests.Responses { users = value; - value.ForEach(u => + foreach (var user in value) { - Comments.ForEach(c => + foreach (var comment in Comments.Concat(IncludedComments).Concat(PinnedComments)) { - if (c.UserId == u.Id) - c.User = u; + if (comment.UserId == user.Id) + comment.User = user; - if (c.EditedById == u.Id) - c.EditedUser = u; - }); - - IncludedComments.ForEach(c => - { - if (c.UserId == u.Id) - c.User = u; - - if (c.EditedById == u.Id) - c.EditedUser = u; - }); - }); + if (comment.EditedById == user.Id) + comment.EditedUser = user; + } + } } } diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index f1cb02fb10..ae082ca82e 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using Humanizer; using JetBrains.Annotations; using osu.Framework.IO.Network; using osu.Game.Extensions; @@ -59,7 +60,7 @@ namespace osu.Game.Online.API.Requests SearchPlayed played = SearchPlayed.Any, SearchExplicit explicitContent = SearchExplicit.Hide) { - this.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query); + this.query = query; this.ruleset = ruleset; this.cursor = cursor; @@ -78,10 +79,12 @@ namespace osu.Game.Online.API.Requests protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); - req.AddParameter("q", query); + + if (query != null) + req.AddParameter("q", query); if (General != null && General.Any()) - req.AddParameter("c", string.Join('.', General.Select(e => e.ToString().ToLowerInvariant()))); + req.AddParameter("c", string.Join('.', General.Select(e => e.ToString().Underscore()))); if (ruleset.ID.HasValue) req.AddParameter("m", ruleset.ID.Value.ToString()); diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 3136a3960d..47d5955fb0 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -256,8 +256,36 @@ namespace osu.Game.Online.Chat JoinChannel(channel); break; + case "chat": + case "msg": + case "query": + if (string.IsNullOrWhiteSpace(content)) + { + target.AddNewMessages(new ErrorMessage($"Usage: /{command} [user]")); + break; + } + + // Check if the user has joined the requested channel already. + // This uses the channel name for comparison as the PM user's username is unavailable after a restart. + var privateChannel = JoinedChannels.FirstOrDefault( + c => c.Type == ChannelType.PM && c.Users.Count == 1 && c.Name.Equals(content, StringComparison.OrdinalIgnoreCase)); + + if (privateChannel != null) + { + CurrentChannel.Value = privateChannel; + break; + } + + var request = new GetUserRequest(content); + request.Success += OpenPrivateChannel; + request.Failure += e => target.AddNewMessages( + new ErrorMessage(e.InnerException?.Message == @"NotFound" ? $"User '{content}' was not found." : $"Could not fetch user '{content}'.")); + + api.Queue(request); + break; + case "help": - target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /np")); + target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /chat [user], /np")); break; default: @@ -553,7 +581,7 @@ namespace osu.Game.Online.Chat if (channel.LastMessageId == channel.LastReadId) return; - var message = channel.Messages.LastOrDefault(); + var message = channel.Messages.FindLast(msg => !(msg is LocalMessage)); if (message == null) return; diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index e7f47833a2..4df60eba69 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -28,9 +28,10 @@ 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) + : base(HoverSampleSet.Submit) { Parts = parts.ToList(); } diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index ae9199c428..201ba6239b 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -43,7 +43,8 @@ namespace osu.Game.Online.Chat RegexOptions.IgnoreCase); // 00:00:000 (1,2,3) - test - private static readonly Regex time_regex = new Regex(@"\d\d:\d\d:\d\d\d? [^-]*"); + // regex from https://github.com/ppy/osu-web/blob/651a9bac2b60d031edd7e33b8073a469bf11edaa/resources/assets/coffee/_classes/beatmap-discussion-helper.coffee#L10 + private static readonly Regex time_regex = new Regex(@"\b(((\d{2,}):([0-5]\d)[:.](\d{3}))(\s\((?:\d+[,|])*\d+\))?)"); // #osu private static readonly Regex channel_regex = new Regex(@"(#[a-zA-Z]+[a-zA-Z0-9]+)"); @@ -176,6 +177,24 @@ namespace osu.Game.Online.Chat case "wiki": return new LinkDetails(LinkAction.OpenWiki, string.Join('/', args.Skip(3))); + + case "home": + if (mainArg != "changelog") + // handle link other than changelog as external for now + return new LinkDetails(LinkAction.External, url); + + switch (args.Length) + { + case 4: + // https://osu.ppy.sh/home/changelog + return new LinkDetails(LinkAction.OpenChangelog, string.Empty); + + case 6: + // https://osu.ppy.sh/home/changelog/lazer/2021.1006 + return new LinkDetails(LinkAction.OpenChangelog, $"{args[4]}/{args[5]}"); + } + + break; } } @@ -323,6 +342,7 @@ namespace osu.Game.Online.Chat SearchBeatmapSet, OpenWiki, Custom, + OpenChangelog, } public class Link : IComparable diff --git a/osu.Game/Online/Chat/NowPlayingCommand.cs b/osu.Game/Online/Chat/NowPlayingCommand.cs index 7756591e03..89eb00a45a 100644 --- a/osu.Game/Online/Chat/NowPlayingCommand.cs +++ b/osu.Game/Online/Chat/NowPlayingCommand.cs @@ -37,27 +37,27 @@ namespace osu.Game.Online.Chat base.LoadComplete(); string verb; - BeatmapInfo beatmap; + BeatmapInfo beatmapInfo; switch (api.Activity.Value) { - case UserActivity.SoloGame solo: + case UserActivity.InGame game: verb = "playing"; - beatmap = solo.Beatmap; + beatmapInfo = game.BeatmapInfo; break; case UserActivity.Editing edit: verb = "editing"; - beatmap = edit.Beatmap; + beatmapInfo = edit.BeatmapInfo; break; default: verb = "listening to"; - beatmap = currentBeatmap.Value.BeatmapInfo; + beatmapInfo = currentBeatmap.Value.BeatmapInfo; break; } - var beatmapString = beatmap.OnlineBeatmapID.HasValue ? $"[{api.WebsiteRootUrl}/b/{beatmap.OnlineBeatmapID} {beatmap}]" : beatmap.ToString(); + var beatmapString = beatmapInfo.OnlineBeatmapID.HasValue ? $"[{api.WebsiteRootUrl}/b/{beatmapInfo.OnlineBeatmapID} {beatmapInfo}]" : beatmapInfo.ToString(); channelManager.PostMessage($"is {verb} {beatmapString}", true, target); Expire(); diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index 8b0caddbc6..6ed2055e65 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Chat; @@ -22,7 +23,7 @@ namespace osu.Game.Online.Chat { public readonly Bindable Channel = new Bindable(); - private readonly FocusedTextBox textbox; + protected readonly ChatTextBox Textbox; protected ChannelManager ChannelManager; @@ -30,6 +31,8 @@ namespace osu.Game.Online.Chat private readonly bool postingTextbox; + protected readonly Box Background; + private const float textbox_height = 30; /// @@ -44,7 +47,7 @@ namespace osu.Game.Online.Chat InternalChildren = new Drawable[] { - new Box + Background = new Box { Colour = Color4.Black, Alpha = 0.8f, @@ -54,7 +57,7 @@ namespace osu.Game.Online.Chat if (postingTextbox) { - AddInternal(textbox = new FocusedTextBox + AddInternal(Textbox = new ChatTextBox { RelativeSizeAxes = Axes.X, Height = textbox_height, @@ -65,7 +68,7 @@ namespace osu.Game.Online.Chat Origin = Anchor.BottomLeft, }); - textbox.OnCommit += postMessage; + Textbox.OnCommit += postMessage; } Channel.BindValueChanged(channelChanged); @@ -82,7 +85,7 @@ namespace osu.Game.Online.Chat private void postMessage(TextBox sender, bool newtext) { - var text = textbox.Text.Trim(); + var text = Textbox.Text.Trim(); if (string.IsNullOrWhiteSpace(text)) return; @@ -92,7 +95,7 @@ namespace osu.Game.Online.Chat else ChannelManager?.PostMessage(text, target: Channel.Value); - textbox.Text = string.Empty; + Textbox.Text = string.Empty; } protected virtual ChatLine CreateMessage(Message message) => new StandAloneMessage(message); @@ -110,6 +113,25 @@ namespace osu.Game.Online.Chat AddInternal(drawableChannel); } + public class ChatTextBox : FocusedTextBox + { + protected override void LoadComplete() + { + base.LoadComplete(); + + BackgroundUnfocused = new Color4(10, 10, 10, 10); + BackgroundFocused = new Color4(10, 10, 10, 255); + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + FocusLost?.Invoke(); + } + + public Action FocusLost; + } + public class StandAloneDrawableChannel : DrawableChannel { public Func CreateChatLineAction; diff --git a/osu.Game/Online/DownloadTrackingComposite.cs b/osu.Game/Online/DownloadTrackingComposite.cs index d9599481e7..2a96051427 100644 --- a/osu.Game/Online/DownloadTrackingComposite.cs +++ b/osu.Game/Online/DownloadTrackingComposite.cs @@ -16,7 +16,7 @@ namespace osu.Game.Online /// public abstract class DownloadTrackingComposite : CompositeDrawable where TModel : class, IEquatable - where TModelManager : class, IModelDownloader + where TModelManager : class, IModelDownloader, IModelManager { protected readonly Bindable Model = new Bindable(); @@ -35,7 +35,7 @@ namespace osu.Game.Online Model.Value = model; } - private IBindable> managedUpdated; + private IBindable> managerUpdated; private IBindable> managerRemoved; private IBindable>> managerDownloadBegan; private IBindable>> managerDownloadFailed; @@ -60,8 +60,8 @@ namespace osu.Game.Online managerDownloadBegan.BindValueChanged(downloadBegan); managerDownloadFailed = Manager.DownloadFailed.GetBoundCopy(); managerDownloadFailed.BindValueChanged(downloadFailed); - managedUpdated = Manager.ItemUpdated.GetBoundCopy(); - managedUpdated.BindValueChanged(itemUpdated); + managerUpdated = Manager.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(itemUpdated); managerRemoved = Manager.ItemRemoved.GetBoundCopy(); managerRemoved.BindValueChanged(itemRemoved); } @@ -77,7 +77,7 @@ namespace osu.Game.Online /// /// Whether the given model is available in the database. - /// By default, this calls , + /// By default, this calls , /// but can be overriden to add additional checks for verifying the model in database. /// protected virtual bool IsModelAvailableLocally() => Manager?.IsAvailableLocally(Model.Value) == true; diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 3839762e46..e9d6960c71 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -26,6 +26,7 @@ namespace osu.Game.Online private readonly string clientName; private readonly string endpoint; private readonly string versionHash; + private readonly bool preferMessagePack; private readonly IAPIProvider api; /// @@ -51,12 +52,14 @@ namespace osu.Game.Online /// The endpoint to the hub. /// An API provider used to react to connection state changes. /// The hash representing the current game version, used for verification purposes. - public HubClientConnector(string clientName, string endpoint, IAPIProvider api, string versionHash) + /// Whether to use MessagePack for serialisation if available on this platform. + public HubClientConnector(string clientName, string endpoint, IAPIProvider api, string versionHash, bool preferMessagePack = true) { this.clientName = clientName; this.endpoint = endpoint; this.api = api; this.versionHash = versionHash; + this.preferMessagePack = preferMessagePack; apiState.BindTo(api.State); apiState.BindValueChanged(state => @@ -116,10 +119,7 @@ namespace osu.Game.Online } catch (Exception e) { - Logger.Log($"{clientName} connection error: {e}", LoggingTarget.Network); - - // retry on any failure. - await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + await handleErrorAndDelay(e, cancellationToken).ConfigureAwait(false); } } } @@ -129,6 +129,15 @@ namespace osu.Game.Online } } + /// + /// Handles an exception and delays an async flow. + /// + private async Task handleErrorAndDelay(Exception exception, CancellationToken cancellationToken) + { + Logger.Log($"{clientName} connection error: {exception}", LoggingTarget.Network); + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + } + private HubConnection buildConnection(CancellationToken cancellationToken) { var builder = new HubConnectionBuilder() @@ -138,13 +147,24 @@ namespace osu.Game.Online options.Headers.Add("OsuVersionHash", versionHash); }); - if (RuntimeInfo.SupportsJIT) - builder.AddMessagePackProtocol(); + if (RuntimeInfo.SupportsJIT && preferMessagePack) + { + builder.AddMessagePackProtocol(options => + { + options.SerializerOptions = SignalRUnionWorkaroundResolver.OPTIONS; + }); + } else { // eventually we will precompile resolvers for messagepack, but this isn't working currently // see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308. - builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }); + builder.AddNewtonsoftJsonProtocol(options => + { + options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; + // TODO: This should only be required to be `TypeNameHandling.Auto`. + // See usage in osu-server-spectator for further documentation as to why this is required. + options.PayloadSerializerSettings.TypeNameHandling = TypeNameHandling.All; + }); } var newConnection = builder.Build(); @@ -155,17 +175,18 @@ namespace osu.Game.Online return newConnection; } - private Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) + private async Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) { isConnected.Value = false; - Logger.Log(ex != null ? $"{clientName} lost connection: {ex}" : $"{clientName} disconnected", LoggingTarget.Network); + if (ex != null) + await handleErrorAndDelay(ex, cancellationToken).ConfigureAwait(false); + else + Logger.Log($"{clientName} disconnected", LoggingTarget.Network); // make sure a disconnect wasn't triggered (and this is still the active connection). if (!cancellationToken.IsCancellationRequested) - Task.Run(connect, default); - - return Task.CompletedTask; + await Task.Run(connect, default).ConfigureAwait(false); } private async Task disconnect(bool takeLock) diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 70e38e421d..e3ac9f603d 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -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(); } @@ -255,6 +255,7 @@ namespace osu.Game.Online.Leaderboards } private APIRequest getScoresRequest; + private ScheduledDelegate getScoresRequestCallback; protected abstract bool IsOnlineScope { get; } @@ -282,13 +283,16 @@ namespace osu.Game.Online.Leaderboards getScoresRequest?.Cancel(); getScoresRequest = null; + getScoresRequestCallback?.Cancel(); + getScoresRequestCallback = null; + pendingUpdateScores?.Cancel(); pendingUpdateScores = Schedule(() => { PlaceholderState = PlaceholderState.Retrieving; loading.Show(); - getScoresRequest = FetchScores(scores => Schedule(() => + getScoresRequest = FetchScores(scores => getScoresRequestCallback = Schedule(() => { Scores = scores.ToArray(); PlaceholderState = Scores.Any() ? PlaceholderState.Successful : PlaceholderState.NoScores; @@ -297,7 +301,7 @@ namespace osu.Game.Online.Leaderboards if (getScoresRequest == null) return; - getScoresRequest.Failure += e => Schedule(() => + getScoresRequest.Failure += e => getScoresRequestCallback = Schedule(() => { if (e is OperationCanceledException) return; diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index e35d3d6461..26749a23f9 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -34,6 +34,8 @@ namespace osu.Game.Online.Leaderboards { public const float HEIGHT = 60; + public readonly ScoreInfo Score; + private const float corner_radius = 5; private const float edge_margin = 5; private const float background_alpha = 0.25f; @@ -41,7 +43,6 @@ namespace osu.Game.Online.Leaderboards protected Container RankContainer { get; private set; } - private readonly ScoreInfo score; private readonly int? rank; private readonly bool allowHighlight; @@ -67,7 +68,8 @@ namespace osu.Game.Online.Leaderboards public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true) { - this.score = score; + Score = score; + this.rank = rank; this.allowHighlight = allowHighlight; @@ -78,9 +80,9 @@ namespace osu.Game.Online.Leaderboards [BackgroundDependencyLoader] private void load(IAPIProvider api, OsuColour colour, ScoreManager scoreManager) { - var user = score.User; + var user = Score.User; - statisticsLabels = GetStatistics(score).Select(s => new ScoreComponentLabel(s)).ToList(); + statisticsLabels = GetStatistics(Score).Select(s => new ScoreComponentLabel(s)).ToList(); ClickableAvatar innerAvatar; @@ -198,7 +200,7 @@ namespace osu.Game.Online.Leaderboards { TextColour = Color4.White, GlowColour = Color4Extensions.FromHex(@"83ccfa"), - Current = scoreManager.GetBindableTotalScoreString(score), + Current = scoreManager.GetBindableTotalScoreString(Score), Font = OsuFont.Numeric.With(size: 23), }, RankContainer = new Container @@ -206,7 +208,7 @@ namespace osu.Game.Online.Leaderboards Size = new Vector2(40f, 20f), Children = new[] { - scoreRank = new UpdateableRank(score.Rank) + scoreRank = new UpdateableRank(Score.Rank) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -223,7 +225,7 @@ namespace osu.Game.Online.Leaderboards AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(1), - ChildrenEnumerable = score.Mods.Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) + ChildrenEnumerable = Score.Mods.Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) }, }, }, @@ -248,7 +250,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); @@ -256,12 +258,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++) @@ -372,10 +374,10 @@ namespace osu.Game.Online.Leaderboards public class LeaderboardScoreStatistic { public IconUsage Icon; - public string Value; + public LocalisableString Value; public string Name; - public LeaderboardScoreStatistic(IconUsage icon, string name, string value) + public LeaderboardScoreStatistic(IconUsage icon, string name, LocalisableString value) { Icon = icon; Name = name; @@ -389,14 +391,14 @@ namespace osu.Game.Online.Leaderboards { List items = new List(); - if (score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) - items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = score.Mods)); + if (Score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) + items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods)); - if (score.Files?.Count > 0) - items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(score))); + if (Score.Files?.Count > 0) + items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(Score))); - if (score.ID != 0) - items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); + if (Score.ID != 0) + items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); return items.ToArray(); } diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index 6d7b9d24d6..8f16d22c4c 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -31,6 +31,15 @@ namespace osu.Game.Online.Multiplayer /// The user. Task UserLeft(MultiplayerRoomUser user); + /// + /// Signals that a user has been kicked from the room. + /// + /// + /// This will also be sent to the user that was kicked. + /// + /// The user. + Task UserKicked(MultiplayerRoomUser user); + /// /// Signal that the host of the room has changed. /// @@ -50,6 +59,25 @@ namespace osu.Game.Online.Multiplayer /// The new state of the user. Task UserStateChanged(int userId, MultiplayerUserState state); + /// + /// Signals that the match type state has changed for a user in this room. + /// + /// The ID of the user performing a state change. + /// The new state of the user. + Task MatchUserStateChanged(int userId, MatchUserState state); + + /// + /// Signals that the match type state has changed for this room. + /// + /// The new state of the room. + Task MatchRoomStateChanged(MatchRoomState state); + + /// + /// Send a match type specific request. + /// + /// The event to handle. + Task MatchEvent(MatchServerEvent e); + /// /// Signals that a user in this room changed their beatmap availability state. /// 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/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 3527ce6314..da637c229f 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -27,6 +27,14 @@ namespace osu.Game.Online.Multiplayer /// If the user is not in a room. Task TransferHost(int userId); + /// + /// As the host, kick another user from the room. + /// + /// The user to kick.. + /// A user other than the current host is attempting to kick a user. + /// If the user is not in a room. + Task KickUser(int userId); + /// /// As the host, update the settings of the currently joined room. /// @@ -55,6 +63,12 @@ namespace osu.Game.Online.Multiplayer /// The proposed new mods, excluding any required by the room itself. Task ChangeUserMods(IEnumerable newMods); + /// + /// Send a match type specific request. + /// + /// The request to send. + Task SendMatchRequest(MatchUserRequest request); + /// /// As the host of a room, start the match. /// 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/MatchRoomState.cs b/osu.Game/Online/Multiplayer/MatchRoomState.cs new file mode 100644 index 0000000000..edd34fb5a3 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchRoomState.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; + +#nullable enable + +namespace osu.Game.Online.Multiplayer +{ + /// + /// Room-wide state for the current match type. + /// Can be used to contain any state which should be used before or during match gameplay. + /// + [Serializable] + [MessagePackObject] + [Union(0, typeof(TeamVersusRoomState))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + // TODO: abstract breaks json serialisation. attention will be required for iOS support (unless we get messagepack AOT working instead). + public abstract class MatchRoomState + { + } +} diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs new file mode 100644 index 0000000000..891fb2cc3b --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchServerEvent.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// An event from the server to allow clients to update gameplay to an expected state. + /// + [Serializable] + [MessagePackObject] + public abstract class MatchServerEvent + { + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs new file mode 100644 index 0000000000..9c3b07049c --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.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 MessagePack; + +#nullable enable + +namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus +{ + public class ChangeTeamRequest : MatchUserRequest + { + [Key(0)] + public int TeamID { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/MultiplayerTeam.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/MultiplayerTeam.cs new file mode 100644 index 0000000000..f952dbc1b5 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/MultiplayerTeam.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; +using MessagePack; + +#nullable enable + +namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus +{ + [Serializable] + [MessagePackObject] + public class MultiplayerTeam + { + [Key(0)] + public int ID { get; set; } + + [Key(1)] + public string Name { get; set; } = string.Empty; + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs new file mode 100644 index 0000000000..91d1aa43d4 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using MessagePack; + +#nullable enable + +namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus +{ + [MessagePackObject] + public class TeamVersusRoomState : MatchRoomState + { + [Key(0)] + public List Teams { get; set; } = new List(); + + public static TeamVersusRoomState CreateDefault() => + new TeamVersusRoomState + { + Teams = + { + new MultiplayerTeam { ID = 0, Name = "Team Red" }, + new MultiplayerTeam { ID = 1, Name = "Team Blue" }, + } + }; + } +} diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs new file mode 100644 index 0000000000..96a4e2ea99 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.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 MessagePack; + +#nullable enable + +namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus +{ + public class TeamVersusUserState : MatchUserState + { + [Key(0)] + public int TeamID { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs new file mode 100644 index 0000000000..8c6809e7f3 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchUserRequest.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; +using MessagePack; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A request from a user to perform an action specific to the current match type. + /// + [Serializable] + [MessagePackObject] + [Union(0, typeof(ChangeTeamRequest))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + public abstract class MatchUserRequest + { + } +} diff --git a/osu.Game/Online/Multiplayer/MatchUserState.cs b/osu.Game/Online/Multiplayer/MatchUserState.cs new file mode 100644 index 0000000000..69245deba0 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MatchUserState.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; + +#nullable enable + +namespace osu.Game.Online.Multiplayer +{ + /// + /// User specific state for the current match type. + /// Can be used to contain any state which should be used before or during match gameplay. + /// + [Serializable] + [MessagePackObject] + [Union(0, typeof(TeamVersusUserState))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. + // TODO: abstract breaks json serialisation. attention will be required for iOS support (unless we get messagepack AOT working instead). + public abstract class MatchUserState + { + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 2e65f7cf1c..75bbaec0ef 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -33,6 +33,12 @@ namespace osu.Game.Online.Multiplayer /// public event Action? RoomUpdated; + public event Action? UserJoined; + + public event Action? UserLeft; + + public event Action? UserKicked; + /// /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. /// @@ -62,7 +68,9 @@ namespace osu.Game.Online.Multiplayer /// /// The users in the joined which are participating in the current gameplay loop. /// - public readonly BindableList CurrentMatchPlayingUserIds = new BindableList(); + public IBindableList CurrentMatchPlayingUserIds => PlayingUserIds; + + protected readonly BindableList PlayingUserIds = new BindableList(); public readonly Bindable CurrentMatchPlayingItem = new Bindable(); @@ -92,7 +100,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 +123,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 +136,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,9 +147,11 @@ namespace osu.Game.Online.Multiplayer await scheduleAsync(() => { Room = joinedRoom; - apiRoom = room; + APIRoom = room; foreach (var user in joinedRoom.Users) updateUserPlayingState(user.UserID, user.State); + + OnRoomJoined(); }, cancellationSource.Token).ConfigureAwait(false); // Update room settings. @@ -148,12 +159,20 @@ namespace osu.Game.Online.Multiplayer }, cancellationSource.Token).ConfigureAwait(false); } + /// + /// Fired when the room join sequence is complete + /// + protected virtual void OnRoomJoined() + { + } + /// /// 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,9 +185,10 @@ 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(); + CurrentMatchPlayingItem.Value = null; + PlayingUserIds.Clear(); RoomUpdated?.Invoke(); }); @@ -189,8 +209,10 @@ 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 type of the match, 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 matchType = default, Optional item = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); @@ -212,9 +234,11 @@ 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, + MatchType = matchType.GetOr(Room.Settings.MatchType), RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods, AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods, }); @@ -275,6 +299,8 @@ namespace osu.Game.Online.Multiplayer public abstract Task TransferHost(int userId); + public abstract Task KickUser(int userId); + public abstract Task ChangeSettings(MultiplayerRoomSettings settings); public abstract Task ChangeState(MultiplayerUserState newState); @@ -289,6 +315,8 @@ namespace osu.Game.Online.Multiplayer public abstract Task ChangeUserMods(IEnumerable newMods); + public abstract Task SendMatchRequest(MatchUserRequest request); + public abstract Task StartMatch(); Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) @@ -301,22 +329,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; } @@ -344,11 +372,26 @@ namespace osu.Game.Online.Multiplayer Room.Users.Add(user); + UserJoined?.Invoke(user); RoomUpdated?.Invoke(); }, false); } - Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) + Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) => + handleUserLeft(user, UserLeft); + + Task IMultiplayerClient.UserKicked(MultiplayerRoomUser user) + { + if (LocalUser == null) + return Task.CompletedTask; + + if (user.Equals(LocalUser)) + LeaveRoom(); + + return handleUserLeft(user, UserKicked); + } + + private Task handleUserLeft(MultiplayerRoomUser user, Action? callback) { if (Room == null) return Task.CompletedTask; @@ -359,8 +402,9 @@ namespace osu.Game.Online.Multiplayer return; Room.Users.Remove(user); - CurrentMatchPlayingUserIds.Remove(user.UserID); + PlayingUserIds.Remove(user.UserID); + callback?.Invoke(user); RoomUpdated?.Invoke(); }, false); @@ -377,12 +421,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); @@ -416,6 +460,46 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + Task IMultiplayerClient.MatchUserStateChanged(int userId, MatchUserState state) + { + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + Room.Users.Single(u => u.UserID == userId).MatchState = state; + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.MatchRoomStateChanged(MatchRoomState state) + { + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + Room.MatchState = state; + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; + } + + public Task MatchEvent(MatchServerEvent e) + { + // not used by any match types just yet. + return Task.CompletedTask; + } + Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) { if (Room == null) @@ -525,11 +609,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 +636,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 +646,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 +654,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; @@ -601,16 +686,16 @@ namespace osu.Game.Online.Multiplayer /// The new state of the user. private void updateUserPlayingState(int userId, MultiplayerUserState state) { - bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId); + bool wasPlaying = PlayingUserIds.Contains(userId); bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay; if (isPlaying == wasPlaying) return; if (isPlaying) - CurrentMatchPlayingUserIds.Add(userId); + PlayingUserIds.Add(userId); else - CurrentMatchPlayingUserIds.Remove(userId); + PlayingUserIds.Remove(userId); } private Task scheduleAsync(Action action, CancellationToken cancellationToken = default) diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index c5fa6253ed..175c0e0e27 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -39,7 +39,7 @@ namespace osu.Game.Online.Multiplayer /// All users currently in this room. /// [Key(3)] - public List Users { get; set; } = new List(); + public IList Users { get; set; } = new List(); /// /// The host of this room, in control of changing room settings. @@ -47,6 +47,9 @@ namespace osu.Game.Online.Multiplayer [Key(4)] public MultiplayerRoomUser? Host { get; set; } + [Key(5)] + public MatchRoomState? MatchState { get; set; } + [JsonConstructor] [SerializationConstructor] public MultiplayerRoom(long roomId) diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index ee72df4c10..001cf2aa93 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Linq; using MessagePack; using osu.Game.Online.API; +using osu.Game.Online.Rooms; namespace osu.Game.Online.Multiplayer { @@ -36,20 +37,30 @@ namespace osu.Game.Online.Multiplayer [Key(6)] public long PlaylistItemId { get; set; } + [Key(7)] + public string Password { get; set; } = string.Empty; + + [Key(8)] + public MatchType MatchType { get; set; } = MatchType.HeadToHead; + 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; + && PlaylistItemId == other.PlaylistItemId + && MatchType == other.MatchType; public override string ToString() => $"Name:{Name}" + $" Beatmap:{BeatmapID} ({BeatmapChecksum})" + $" RequiredMods:{string.Join(',', RequiredMods)}" + $" AllowedMods:{string.Join(',', AllowedMods)}" + + $" Password:{(string.IsNullOrEmpty(Password) ? "no" : "yes")}" + $" Ruleset:{RulesetID}" + + $" Type:{MatchType}" + $" Item:{PlaylistItemId}"; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index a49a8f083c..5d11e2921a 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -24,6 +24,9 @@ namespace osu.Game.Online.Multiplayer [Key(1)] public MultiplayerUserState State { get; set; } = MultiplayerUserState.Idle; + [Key(4)] + public MatchUserState? MatchState { get; set; } + /// /// The availability state of the current beatmap. /// diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index cf1e18e059..965674c2f2 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -37,6 +37,8 @@ namespace osu.Game.Online.Multiplayer [BackgroundDependencyLoader] private void load(IAPIProvider api) { + // Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization. + // More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code. connector = api.GetHubConnector(nameof(OnlineMultiplayerClient), endpoint); if (connector != null) @@ -48,6 +50,7 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); + connection.On(nameof(IMultiplayerClient.UserKicked), ((IMultiplayerClient)this).UserKicked); connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); @@ -56,18 +59,21 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); + connection.On(nameof(IMultiplayerClient.MatchRoomStateChanged), ((IMultiplayerClient)this).MatchRoomStateChanged); + connection.On(nameof(IMultiplayerClient.MatchUserStateChanged), ((IMultiplayerClient)this).MatchUserStateChanged); + connection.On(nameof(IMultiplayerClient.MatchEvent), ((IMultiplayerClient)this).MatchEvent); }; IsConnected.BindTo(connector.IsConnected); } } - 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() @@ -86,6 +92,14 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId); } + public override Task KickUser(int userId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.KickUser), userId); + } + public override Task ChangeSettings(MultiplayerRoomSettings settings) { if (!IsConnected.Value) @@ -118,6 +132,14 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods); } + public override Task SendMatchRequest(MatchUserRequest request) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.SendMatchRequest), request); + } + public override Task StartMatch() { if (!IsConnected.Value) diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs index 3d19f2ab09..243be8da44 100644 --- a/osu.Game/Online/PollingComponent.cs +++ b/osu.Game/Online/PollingComponent.cs @@ -47,39 +47,13 @@ namespace osu.Game.Online pollIfNecessary(); } - private bool pollIfNecessary() + /// + /// Immediately performs a . + /// + public void PollImmediately() { - // we must be loaded so we have access to clock. - if (!IsLoaded) return false; - - // there's already a poll process running. - if (pollingActive) return false; - - // don't try polling if the time between polls hasn't been set. - if (TimeBetweenPolls.Value == 0) return false; - - if (!lastTimePolled.HasValue) - { - doPoll(); - return true; - } - - if (Time.Current - lastTimePolled.Value > TimeBetweenPolls.Value) - { - doPoll(); - return true; - } - - // not ennough time has passed since the last poll. we do want to schedule a poll to happen, though. + lastTimePolled = Time.Current - TimeBetweenPolls.Value; scheduleNextPoll(); - return false; - } - - private void doPoll() - { - scheduledPoll = null; - pollingActive = true; - Poll().ContinueWith(_ => pollComplete()); } /// @@ -90,13 +64,11 @@ namespace osu.Game.Online return Task.CompletedTask; } - /// - /// Immediately performs a . - /// - public void PollImmediately() + private void doPoll() { - lastTimePolled = Time.Current - TimeBetweenPolls.Value; - scheduleNextPoll(); + scheduledPoll = null; + pollingActive = true; + Poll().ContinueWith(_ => pollComplete()); } /// @@ -111,6 +83,33 @@ namespace osu.Game.Online pollIfNecessary(); } + private void pollIfNecessary() + { + // we must be loaded so we have access to clock. + if (!IsLoaded) return; + + // there's already a poll process running. + if (pollingActive) return; + + // don't try polling if the time between polls hasn't been set. + if (TimeBetweenPolls.Value == 0) return; + + if (!lastTimePolled.HasValue) + { + doPoll(); + return; + } + + if (Time.Current - lastTimePolled.Value > TimeBetweenPolls.Value) + { + doPoll(); + return; + } + + // not enough time has passed since the last poll. we do want to schedule a poll to happen, though. + scheduleNextPoll(); + } + private void scheduleNextPoll() { scheduledPoll?.Cancel(); diff --git a/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs b/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs index 973dccd528..00623282d3 100644 --- a/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs +++ b/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs @@ -13,9 +13,9 @@ namespace osu.Game.Online.Rooms [JsonProperty("checksum")] public string Checksum { get; set; } - public override BeatmapInfo ToBeatmap(RulesetStore rulesets) + public override BeatmapInfo ToBeatmapInfo(RulesetStore rulesets) { - var b = base.ToBeatmap(rulesets); + var b = base.ToBeatmapInfo(rulesets); b.MD5Hash = Checksum; return b; } diff --git a/osu.Game/Online/Rooms/GameType.cs b/osu.Game/Online/Rooms/GameType.cs deleted file mode 100644 index caa352d812..0000000000 --- a/osu.Game/Online/Rooms/GameType.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Game.Graphics; - -namespace osu.Game.Online.Rooms -{ - public abstract class GameType - { - public abstract string Name { get; } - - public abstract Drawable GetIcon(OsuColour colours, float size); - - public override int GetHashCode() => GetType().GetHashCode(); - public override bool Equals(object obj) => GetType() == obj?.GetType(); - } -} diff --git a/osu.Game/Online/Rooms/GameTypes/GameTypePlaylists.cs b/osu.Game/Online/Rooms/GameTypes/GameTypePlaylists.cs deleted file mode 100644 index 3425c6c5cd..0000000000 --- a/osu.Game/Online/Rooms/GameTypes/GameTypePlaylists.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osuTK; - -namespace osu.Game.Online.Rooms.GameTypes -{ - public class GameTypePlaylists : GameType - { - public override string Name => "Playlists"; - - public override Drawable GetIcon(OsuColour colours, float size) => new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = FontAwesome.Regular.Clock, - Size = new Vector2(size), - Colour = colours.Blue, - Shadow = false - }; - } -} diff --git a/osu.Game/Online/Rooms/GameTypes/GameTypeTag.cs b/osu.Game/Online/Rooms/GameTypes/GameTypeTag.cs deleted file mode 100644 index e468612738..0000000000 --- a/osu.Game/Online/Rooms/GameTypes/GameTypeTag.cs +++ /dev/null @@ -1,28 +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.Sprites; -using osu.Game.Graphics; -using osuTK; - -namespace osu.Game.Online.Rooms.GameTypes -{ - public class GameTypeTag : GameType - { - public override string Name => "Tag"; - - public override Drawable GetIcon(OsuColour colours, float size) - { - return new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = FontAwesome.Solid.Sync, - Size = new Vector2(size), - Colour = colours.Blue, - Shadow = false, - }; - } - } -} diff --git a/osu.Game/Online/Rooms/GameTypes/GameTypeTagTeam.cs b/osu.Game/Online/Rooms/GameTypes/GameTypeTagTeam.cs deleted file mode 100644 index b82f203fac..0000000000 --- a/osu.Game/Online/Rooms/GameTypes/GameTypeTagTeam.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osuTK; - -namespace osu.Game.Online.Rooms.GameTypes -{ - public class GameTypeTagTeam : GameType - { - public override string Name => "Tag Team"; - - public override Drawable GetIcon(OsuColour colours, float size) - { - return new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(2f), - Children = new[] - { - new SpriteIcon - { - Icon = FontAwesome.Solid.Sync, - Size = new Vector2(size * 0.75f), - Colour = colours.Blue, - Shadow = false, - }, - new SpriteIcon - { - Icon = FontAwesome.Solid.Sync, - Size = new Vector2(size * 0.75f), - Colour = colours.Pink, - Shadow = false, - }, - }, - }; - } - } -} diff --git a/osu.Game/Online/Rooms/GameTypes/GameTypeTeamVersus.cs b/osu.Game/Online/Rooms/GameTypes/GameTypeTeamVersus.cs deleted file mode 100644 index 5ad4033dc9..0000000000 --- a/osu.Game/Online/Rooms/GameTypes/GameTypeTeamVersus.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osuTK; - -namespace osu.Game.Online.Rooms.GameTypes -{ - public class GameTypeTeamVersus : GameType - { - public override string Name => "Team Versus"; - - public override Drawable GetIcon(OsuColour colours, float size) - { - return new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(2f), - Children = new[] - { - new VersusRow(colours.Blue, colours.Pink, size * 0.5f), - new VersusRow(colours.Blue, colours.Pink, size * 0.5f), - }, - }; - } - } -} diff --git a/osu.Game/Online/Rooms/GameTypes/GameTypeVersus.cs b/osu.Game/Online/Rooms/GameTypes/GameTypeVersus.cs deleted file mode 100644 index 3783cc67b0..0000000000 --- a/osu.Game/Online/Rooms/GameTypes/GameTypeVersus.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.Framework.Graphics; -using osu.Game.Graphics; - -namespace osu.Game.Online.Rooms.GameTypes -{ - public class GameTypeVersus : GameType - { - public override string Name => "Versus"; - - public override Drawable GetIcon(OsuColour colours, float size) - { - return new VersusRow(colours.Blue, colours.Blue, size * 0.6f) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; - } - } -} diff --git a/osu.Game/Online/Rooms/GameTypes/VersusRow.cs b/osu.Game/Online/Rooms/GameTypes/VersusRow.cs deleted file mode 100644 index 0bd09a23ac..0000000000 --- a/osu.Game/Online/Rooms/GameTypes/VersusRow.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Online.Rooms.GameTypes -{ - public class VersusRow : FillFlowContainer - { - public VersusRow(Color4 first, Color4 second, float size) - { - var triangleSize = new Vector2(size); - AutoSizeAxes = Axes.Both; - Spacing = new Vector2(2f, 0f); - - Children = new[] - { - new Container - { - Size = triangleSize, - Colour = first, - Children = new[] - { - new EquilateralTriangle - { - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Rotation = 90, - EdgeSmoothness = new Vector2(1f), - }, - }, - }, - new Container - { - Size = triangleSize, - Colour = second, - Children = new[] - { - new EquilateralTriangle - { - Anchor = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Rotation = -90, - EdgeSmoothness = new Vector2(1f), - }, - }, - }, - }; - } - } -} diff --git a/osu.Game/Online/Rooms/JoinRoomRequest.cs b/osu.Game/Online/Rooms/JoinRoomRequest.cs index faa20a3e6c..2a3480c992 100644 --- a/osu.Game/Online/Rooms/JoinRoomRequest.cs +++ b/osu.Game/Online/Rooms/JoinRoomRequest.cs @@ -9,20 +9,24 @@ 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() { var req = base.CreateWebRequest(); req.Method = HttpMethod.Put; + if (!string.IsNullOrEmpty(Password)) + req.AddParameter(@"password", Password, RequestParameterType.Query); return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; + protected override string Target => $@"rooms/{Room.RoomID.Value}/users/{User.Id}"; } } diff --git a/osu.Game/Online/Rooms/MatchType.cs b/osu.Game/Online/Rooms/MatchType.cs new file mode 100644 index 0000000000..36f0dc0c81 --- /dev/null +++ b/osu.Game/Online/Rooms/MatchType.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.Online.Rooms +{ + public enum MatchType + { + // used for osu-web deserialization so names shouldn't be changed. + + Playlists, + + [Description("Head to head")] + HeadToHead, + + [Description("Team VS")] + TeamVersus, + } +} diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index 30c1d2f826..7ec34e70d5 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -70,7 +70,7 @@ namespace osu.Game.Online.Rooms OnlineScoreID = ID, TotalScore = TotalScore, MaxCombo = MaxCombo, - Beatmap = playlistItem.Beatmap.Value, + BeatmapInfo = playlistItem.Beatmap.Value, BeatmapInfoID = playlistItem.BeatmapID, Ruleset = playlistItem.Ruleset.Value, RulesetID = playlistItem.RulesetID, diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 72ea84d4a8..86879ba245 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -59,8 +59,8 @@ namespace osu.Game.Online.Rooms protected override bool VerifyDatabasedModel(BeatmapSetInfo databasedSet) { - int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID; - string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; + int? beatmapId = SelectedItem.Value?.Beatmap.Value.OnlineBeatmapID; + string checksum = SelectedItem.Value?.Beatmap.Value.MD5Hash; var matchingBeatmap = databasedSet.Beatmaps.FirstOrDefault(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 1d409d4b56..48f1347fa1 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -70,7 +70,7 @@ namespace osu.Game.Online.Rooms public void MapObjects(BeatmapManager beatmaps, RulesetStore rulesets) { - Beatmap.Value ??= apiBeatmap.ToBeatmap(rulesets); + Beatmap.Value ??= apiBeatmap.ToBeatmapInfo(rulesets); Ruleset.Value ??= rulesets.GetRuleset(RulesetID); Ruleset rulesetInstance = Ruleset.Value.CreateInstance(); diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index b28680ffef..39fc7f1da8 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -7,13 +7,13 @@ using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; 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 +48,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(); @@ -66,7 +62,16 @@ namespace osu.Game.Online.Rooms [Cached] [JsonIgnore] - public readonly Bindable Type = new Bindable(new GameTypePlaylists()); + public readonly Bindable Type = new Bindable(); + + // Todo: osu-framework bug (https://github.com/ppy/osu-framework/issues/4106) + [JsonConverter(typeof(SnakeCaseStringEnumConverter))] + [JsonProperty("type")] + private MatchType type + { + get => Type.Value; + set => Type.Value = value; + } [Cached] [JsonIgnore] @@ -76,6 +81,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 +92,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 +115,8 @@ namespace osu.Game.Online.Rooms } } + #endregion + // Only supports retrieval for now [Cached] [JsonProperty("ends_at")] @@ -110,17 +130,16 @@ namespace osu.Game.Online.Rooms set => MaxAttempts.Value = value; } - /// - /// The position of this in the list. This is not read from or written to the API. - /// - [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 +163,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; @@ -153,11 +173,7 @@ namespace osu.Game.Online.Rooms if (EndDate.Value != null && DateTimeOffset.Now >= EndDate.Value) Status.Value = new RoomStatusEnded(); - // Todo: This is not the best way/place to do this, but the intention is to display all playlist items when the room has ended, - // and display only the non-expired playlist items while the room is still active. In order to achieve this, all expired items are removed from the source Room. - // More refactoring is required before this can be done locally instead - DrawableRoomPlaylist is currently directly bound to the playlist to display items in the room. - if (!(Status.Value is RoomStatusEnded)) - other.Playlist.RemoveAll(i => i.Expired); + other.RemoveExpiredPlaylistItems(); if (!Playlist.SequenceEqual(other.Playlist)) { @@ -170,8 +186,15 @@ namespace osu.Game.Online.Rooms RecentParticipants.Clear(); RecentParticipants.AddRange(other.RecentParticipants); } + } - Position.Value = other.Position.Value; + public void RemoveExpiredPlaylistItems() + { + // Todo: This is not the best way/place to do this, but the intention is to display all playlist items when the room has ended, + // and display only the non-expired playlist items while the room is still active. In order to achieve this, all expired items are removed from the source Room. + // More refactoring is required before this can be done locally instead - DrawableRoomPlaylist is currently directly bound to the playlist to display items in the room. + if (!(Status.Value is RoomStatusEnded)) + Playlist.RemoveAll(i => i.Expired); } public bool ShouldSerializeRoomID() => false; diff --git a/osu.Game/Online/Rooms/RoomCategory.cs b/osu.Game/Online/Rooms/RoomCategory.cs index bb9f1298d3..a1dcfa5fd9 100644 --- a/osu.Game/Online/Rooms/RoomCategory.cs +++ b/osu.Game/Online/Rooms/RoomCategory.cs @@ -8,6 +8,5 @@ namespace osu.Game.Online.Rooms // used for osu-web deserialization so names shouldn't be changed. Normal, Spotlight, - Realtime, } } diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs index c852f86f6b..01f3ae368b 100644 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs +++ b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs @@ -8,7 +8,7 @@ namespace osu.Game.Online.Rooms.RoomStatuses { public class RoomStatusEnded : RoomStatus { - public override string Message => @"Ended"; + public override string Message => "Ended"; public override Color4 GetAppropriateColour(OsuColour colours) => colours.YellowDarker; } } diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs index 4f7f0d6f5d..686d4f4033 100644 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs +++ b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs @@ -8,7 +8,7 @@ namespace osu.Game.Online.Rooms.RoomStatuses { public class RoomStatusOpen : RoomStatus { - public override string Message => @"Welcoming Players"; + public override string Message => "Open"; public override Color4 GetAppropriateColour(OsuColour colours) => colours.GreenLight; } } diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs index f04f1b23af..83f1acf52a 100644 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs +++ b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs @@ -8,7 +8,7 @@ namespace osu.Game.Online.Rooms.RoomStatuses { public class RoomStatusPlaying : RoomStatus { - public override string Message => @"Now Playing"; + public override string Message => "Playing"; public override Color4 GetAppropriateColour(OsuColour colours) => colours.Purple; } } diff --git a/osu.Game/Online/SignalRUnionWorkaroundResolver.cs b/osu.Game/Online/SignalRUnionWorkaroundResolver.cs new file mode 100644 index 0000000000..e44da044cc --- /dev/null +++ b/osu.Game/Online/SignalRUnionWorkaroundResolver.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using MessagePack; +using MessagePack.Formatters; +using MessagePack.Resolvers; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; + +namespace osu.Game.Online +{ + /// + /// Handles SignalR being unable to comprehend [Union] types correctly by redirecting to a known base (union) type. + /// See https://github.com/dotnet/aspnetcore/issues/7298. + /// + public class SignalRUnionWorkaroundResolver : IFormatterResolver + { + public static readonly MessagePackSerializerOptions OPTIONS = + MessagePackSerializerOptions.Standard.WithResolver(new SignalRUnionWorkaroundResolver()); + + private static readonly Dictionary formatter_map = new Dictionary + { + { typeof(TeamVersusUserState), new TypeRedirectingFormatter() }, + { typeof(TeamVersusRoomState), new TypeRedirectingFormatter() }, + { typeof(ChangeTeamRequest), new TypeRedirectingFormatter() }, + + // These should not be required. The fallback should work. But something is weird with the way caching is done. + // For future adventurers, I would not advise looking into this further. It's likely not worth the effort. + { typeof(MatchUserState), new TypeRedirectingFormatter() }, + { typeof(MatchRoomState), new TypeRedirectingFormatter() }, + { typeof(MatchUserRequest), new TypeRedirectingFormatter() }, + { typeof(MatchServerEvent), new TypeRedirectingFormatter() }, + }; + + public IMessagePackFormatter GetFormatter() + { + if (formatter_map.TryGetValue(typeof(T), out var formatter)) + return (IMessagePackFormatter)formatter; + + return StandardResolver.Instance.GetFormatter(); + } + + public class TypeRedirectingFormatter : IMessagePackFormatter + { + private readonly IMessagePackFormatter baseFormatter; + + public TypeRedirectingFormatter() + { + baseFormatter = StandardResolver.Instance.GetFormatter(); + } + + public void Serialize(ref MessagePackWriter writer, TActual value, MessagePackSerializerOptions options) => + baseFormatter.Serialize(ref writer, (TBase)(object)value, StandardResolver.Options); + + public TActual Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) => + (TActual)(object)baseFormatter.Deserialize(ref reader, StandardResolver.Options); + } + } +} diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 2546374b21..b597b2f214 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -15,8 +15,6 @@ using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Replays.Legacy; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; using osu.Game.Scoring; @@ -46,15 +44,8 @@ namespace osu.Game.Online.Spectator private readonly BindableDictionary playingUserStates = new BindableDictionary(); private IBeatmap? currentBeatmap; - private Score? currentScore; - [Resolved] - private IBindable currentRuleset { get; set; } = null!; - - [Resolved] - private IBindable> currentMods { get; set; } = null!; - private readonly SpectatorState currentState = new SpectatorState(); /// @@ -143,7 +134,7 @@ namespace osu.Game.Online.Spectator return Task.CompletedTask; } - public void BeginPlaying(GameplayBeatmap beatmap, Score score) + public void BeginPlaying(GameplayState state, Score score) { Debug.Assert(ThreadSafety.IsUpdateThread); @@ -153,11 +144,11 @@ namespace osu.Game.Online.Spectator IsPlaying = true; // transfer state at point of beginning play - currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID; - currentState.RulesetID = currentRuleset.Value.ID; - currentState.Mods = currentMods.Value.Select(m => new APIMod(m)); + currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineBeatmapID; + currentState.RulesetID = score.ScoreInfo.RulesetID; + currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); - currentBeatmap = beatmap.PlayableBeatmap; + currentBeatmap = state.Beatmap; currentScore = score; BeginPlayingInternal(currentState); diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index c25b520892..020cdebab6 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -22,11 +22,11 @@ using Humanizer; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Bindables; -using osu.Framework.Development; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Collections; @@ -62,8 +62,13 @@ namespace osu.Game /// The full osu! experience. Builds on top of to add menus and binding logic /// for initial components that are generally retrieved via DI. /// - public class OsuGame : OsuGameBase, IKeyBindingHandler + public class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUserPlayInfo { + /// + /// The amount of global offset to apply when a left/right anchored overlay is displayed (ie. settings or notifications). + /// + protected const float SIDE_OVERLAY_OFFSET_RATIO = 0.05f; + public Toolbar Toolbar; private ChatOverlay chatOverlay; @@ -71,7 +76,7 @@ namespace osu.Game private ChannelManager channelManager; [NotNull] - private readonly NotificationOverlay notifications = new NotificationOverlay(); + protected readonly NotificationOverlay Notifications = new NotificationOverlay(); private BeatmapListingOverlay beatmapListing; @@ -85,6 +90,8 @@ namespace osu.Game private WikiOverlay wikiOverlay; + private ChangelogOverlay changelogOverlay; + private SkinEditorOverlay skinEditor; private Container overlayContent; @@ -97,7 +104,9 @@ namespace osu.Game private ScalingContainer screenContainer; - private Container screenOffsetContainer; + protected Container ScreenOffsetContainer { get; private set; } + + private Container overlayOffsetContainer; [Resolved] private FrameworkConfigManager frameworkConfig { get; set; } @@ -115,7 +124,7 @@ namespace osu.Game public virtual StableStorage GetStorageForStableInstall() => null; - public float ToolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0); + private float toolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0); private IdleTracker idleTracker; @@ -153,7 +162,7 @@ namespace osu.Game private readonly string[] args; - private readonly List overlays = new List(); + private readonly List focusedOverlays = new List(); private readonly List visibleBlockingOverlays = new List(); @@ -188,7 +197,7 @@ namespace osu.Game /// Whether the toolbar should also be hidden. public void CloseAllOverlays(bool hideToolbar = true) { - foreach (var overlay in overlays) + foreach (var overlay in focusedOverlays) overlay.Hide(); if (hideToolbar) Toolbar.Hide(); @@ -202,19 +211,6 @@ namespace osu.Game [BackgroundDependencyLoader] private void load() { - if (!Host.IsPrimaryInstance && !DebugUtils.IsDebugBuild) - { - Logger.Log(@"osu! does not support multiple running instances.", LoggingTarget.Runtime, LogLevel.Error); - Environment.Exit(0); - } - - if (args?.Length > 0) - { - var paths = args.Where(a => !a.StartsWith('-')).ToArray(); - if (paths.Length > 0) - Task.Run(() => Import(paths)); - } - dependencies.CacheAs(this); dependencies.Cache(SentryLogger); @@ -312,7 +308,7 @@ namespace osu.Game case LinkAction.OpenEditorTimestamp: case LinkAction.JoinMultiplayerMatch: case LinkAction.Spectate: - waitForReady(() => notifications, _ => notifications.Post(new SimpleNotification + waitForReady(() => Notifications, _ => Notifications.Post(new SimpleNotification { Text = @"This link type is not yet supported!", Icon = FontAwesome.Solid.LifeRing, @@ -326,12 +322,26 @@ namespace osu.Game case LinkAction.OpenUserProfile: if (int.TryParse(link.Argument, out int userId)) ShowUser(userId); + else + ShowUser(link.Argument); + break; case LinkAction.OpenWiki: ShowWiki(link.Argument); break; + case LinkAction.OpenChangelog: + if (string.IsNullOrEmpty(link.Argument)) + ShowChangelogListing(); + else + { + var changelogArgs = link.Argument.Split("/"); + ShowChangelogBuild(changelogArgs[0], changelogArgs[1]); + } + + break; + default: throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action."); } @@ -373,6 +383,12 @@ namespace osu.Game /// The user to display. public void ShowUser(int userId) => waitForReady(() => userProfile, _ => userProfile.ShowUser(userId)); + /// + /// Show a user's profile as an overlay. + /// + /// The user to display. + public void ShowUser(string username) => waitForReady(() => userProfile, _ => userProfile.ShowUser(username)); + /// /// Show a beatmap's set as an overlay, displaying the given beatmap. /// @@ -391,6 +407,18 @@ namespace osu.Game /// The wiki page to show public void ShowWiki(string path) => waitForReady(() => wikiOverlay, _ => wikiOverlay.ShowPage(path)); + /// + /// Show changelog listing overlay + /// + public void ShowChangelogListing() => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowListing()); + + /// + /// Show changelog's build as an overlay + /// + /// The update stream name + /// The build version of the update stream + public void ShowChangelogBuild(string updateStream, string version) => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowBuild(updateStream, version)); + /// /// Present a beatmap at song select immediately. /// The user should have already requested this interactively. @@ -472,7 +500,7 @@ namespace osu.Game return; } - var databasedBeatmap = BeatmapManager.QueryBeatmap(b => b.ID == databasedScoreInfo.Beatmap.ID); + var databasedBeatmap = BeatmapManager.QueryBeatmap(b => b.ID == databasedScoreInfo.BeatmapInfo.ID); if (databasedBeatmap == null) { @@ -525,16 +553,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; @@ -546,19 +569,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; @@ -629,13 +639,13 @@ namespace osu.Game MenuCursorContainer.CanShowCursor = menuScreen?.CursorVisible ?? false; // todo: all archive managers should be able to be looped here. - SkinManager.PostNotification = n => notifications.Post(n); + SkinManager.PostNotification = n => Notifications.Post(n); - BeatmapManager.PostNotification = n => notifications.Post(n); - BeatmapManager.PresentImport = items => PresentBeatmap(items.First()); + BeatmapManager.PostNotification = n => Notifications.Post(n); + BeatmapManager.PresentImport = items => PresentBeatmap(items.First().Value); - ScoreManager.PostNotification = n => notifications.Post(n); - ScoreManager.PresentImport = items => PresentScore(items.First()); + ScoreManager.PostNotification = n => Notifications.Post(n); + ScoreManager.PostImport = items => PresentScore(items.First().Value); // make config aware of how to lookup skins for on-screen display purposes. // if this becomes a more common thing, tracked settings should be reconsidered to allow local DI. @@ -673,7 +683,7 @@ namespace osu.Game ActionRequested = action => volume.Adjust(action), ScrollActionRequested = (action, amount, isPrecise) => volume.Adjust(action, amount, isPrecise), }, - screenOffsetContainer = new Container + ScreenOffsetContainer = new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -705,9 +715,16 @@ namespace osu.Game }, } }, - overlayContent = new Container { RelativeSizeAxes = Axes.Both }, - rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, - leftFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, + overlayOffsetContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + overlayContent = new Container { RelativeSizeAxes = Axes.Both }, + rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, + leftFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, + } + }, topMostOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, idleTracker, new ConfineMouseTracker() @@ -742,16 +759,15 @@ namespace osu.Game loadComponentSingleFile(onScreenDisplay, Add, true); - loadComponentSingleFile(notifications.With(d => + loadComponentSingleFile(Notifications.With(d => { - d.GetToolbarHeight = () => ToolbarOffset; d.Anchor = Anchor.TopRight; d.Origin = Anchor.TopRight; }), rightFloatingOverlayContent.Add, true); loadComponentSingleFile(new CollectionManager(Storage) { - PostNotification = n => notifications.Post(n), + PostNotification = n => Notifications.Post(n), }, Add, true); loadComponentSingleFile(stableImportManager, Add); @@ -770,23 +786,21 @@ namespace osu.Game 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(Settings = new SettingsOverlay(), leftFloatingOverlayContent.Add, true); + loadComponentSingleFile(changelogOverlay = new ChangelogOverlay(), overlayContent.Add, true); loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); - loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add); + loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { - GetToolbarHeight = () => ToolbarOffset, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, }, rightFloatingOverlayContent.Add, true); loadComponentSingleFile(new NowPlayingOverlay { - GetToolbarHeight = () => ToolbarOffset, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, }, rightFloatingOverlayContent.Add, true); @@ -803,7 +817,7 @@ namespace osu.Game Add(new MusicKeyBindingHandler()); // side overlays which cancel each other. - var singleDisplaySideOverlays = new OverlayContainer[] { Settings, notifications }; + var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications }; foreach (var overlay in singleDisplaySideOverlays) { @@ -847,20 +861,18 @@ namespace osu.Game if (mode.NewValue != OverlayActivation.All) CloseAllOverlays(); }; - void updateScreenOffset() + // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. + handleStartupImport(); + } + + private void handleStartupImport() + { + if (args?.Length > 0) { - float offset = 0; - - if (Settings.State.Value == Visibility.Visible) - offset += Toolbar.HEIGHT / 2; - if (notifications.State.Value == Visibility.Visible) - offset -= Toolbar.HEIGHT / 2; - - screenOffsetContainer.MoveToX(offset, SettingsPanel.TRANSITION_LENGTH, Easing.OutQuint); + var paths = args.Where(a => !a.StartsWith('-')).ToArray(); + if (paths.Length > 0) + Task.Run(() => Import(paths)); } - - Settings.State.ValueChanged += _ => updateScreenOffset(); - notifications.State.ValueChanged += _ => updateScreenOffset(); } private void showOverlayAboveOthers(OverlayContainer overlay, OverlayContainer[] otherOverlays) @@ -892,7 +904,7 @@ namespace osu.Game if (recentLogCount < short_term_display_limit) { - Schedule(() => notifications.Post(new SimpleErrorNotification + Schedule(() => Notifications.Post(new SimpleErrorNotification { Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb, Text = entry.Message.Truncate(256) + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), @@ -900,7 +912,7 @@ namespace osu.Game } else if (recentLogCount == short_term_display_limit) { - Schedule(() => notifications.Post(new SimpleNotification + Schedule(() => Notifications.Post(new SimpleNotification { Icon = FontAwesome.Solid.EllipsisH, Text = "Subsequent messages have been logged. Click to view log files.", @@ -932,8 +944,8 @@ namespace osu.Game if (cache) dependencies.CacheAs(component); - if (component is OverlayContainer overlay) - overlays.Add(overlay); + if (component is OsuFocusedOverlayContainer overlay) + focusedOverlays.Add(overlay); // schedule is here to ensure that all component loads are done after LoadComplete is run (and thus all dependencies are cached). // with some better organisation of LoadComplete to do construction and dependency caching in one step, followed by calls to loadComponentSingleFile, @@ -950,7 +962,7 @@ namespace osu.Game try { - Logger.Log($"Loading {component}...", level: LogLevel.Debug); + Logger.Log($"Loading {component}..."); // Since this is running in a separate thread, it is possible for OsuGame to be disposed after LoadComponentAsync has been called // throwing an exception. To avoid this, the call is scheduled on the update thread, which does not run if IsDisposed = true @@ -970,7 +982,7 @@ namespace osu.Game await task.ConfigureAwait(false); - Logger.Log($"Loaded {component}!", level: LogLevel.Debug); + Logger.Log($"Loaded {component}!"); } catch (OperationCanceledException) { @@ -981,11 +993,11 @@ namespace osu.Game return component; } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { if (introScreen == null) return false; - switch (action) + switch (e.Action) { case GlobalAction.ResetInputSettings: Host.ResetInputHandlers(); @@ -1019,7 +1031,7 @@ namespace osu.Game #endregion - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -1041,8 +1053,20 @@ namespace osu.Game { base.UpdateAfterChildren(); - screenOffsetContainer.Padding = new MarginPadding { Top = ToolbarOffset }; - overlayContent.Padding = new MarginPadding { Top = ToolbarOffset }; + ScreenOffsetContainer.Padding = new MarginPadding { Top = toolbarOffset }; + overlayOffsetContainer.Padding = new MarginPadding { Top = toolbarOffset }; + + var horizontalOffset = 0f; + + // Content.ToLocalSpace() is used instead of this.ToLocalSpace() to correctly calculate the offset with scaling modes active. + // Content is a child of a scaling container with ScalingMode.Everything set, while the game itself is never scaled. + // this avoids a visible jump in the positioning of the screen offset container. + if (Settings.IsLoaded && Settings.IsPresent) + horizontalOffset += Content.ToLocalSpace(Settings.ScreenSpaceDrawQuad.TopRight).X * SIDE_OVERLAY_OFFSET_RATIO; + if (Notifications.IsLoaded && Notifications.IsPresent) + horizontalOffset += (Content.ToLocalSpace(Notifications.ScreenSpaceDrawQuad.TopLeft).X - Content.DrawWidth) * SIDE_OVERLAY_OFFSET_RATIO; + + ScreenOffsetContainer.X = horizontalOffset; MenuCursorContainer.CanShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; } @@ -1076,8 +1100,6 @@ namespace osu.Game OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); API.Activity.BindTo(newOsuScreen.Activity); - MusicController.AllowRateAdjustments = newOsuScreen.AllowRateAdjustments; - if (newOsuScreen.HideOverlaysOnEnter) CloseAllOverlays(); else @@ -1104,5 +1126,7 @@ namespace osu.Game if (newScreen == null) Exit(); } + + IBindable ILocalUserPlayInfo.IsPlaying => LocalUserPlaying; } } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 5878727ad8..09eb482d16 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -13,24 +14,23 @@ using osu.Framework.Development; using osu.Framework.Extensions; 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.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Cursor; -using osu.Game.Online.API; using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Textures; using osu.Framework.Input; +using osu.Framework.IO.Stores; using osu.Framework.Logging; -using osu.Framework.Threading; +using osu.Framework.Platform; using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Cursor; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Online; +using osu.Game.Online.API; using osu.Game.Online.Chat; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; @@ -56,6 +56,11 @@ namespace osu.Game public const int SAMPLE_CONCURRENCY = 6; + /// + /// Length of debounce (in milliseconds) for commonly occuring sample playbacks that could stack. + /// + public const int SAMPLE_DEBOUNCE_TIME = 20; + /// /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. /// @@ -135,8 +140,6 @@ namespace osu.Game private FileStore fileStore; - private SettingsStore settingsStore; - private RulesetConfigCache rulesetConfigCache; private SpectatorClient spectatorClient; @@ -157,8 +160,6 @@ namespace osu.Game private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(GLOBAL_TRACK_VOLUME_ADJUST); - private IBindable updateThreadState; - public OsuGameBase() { UseDevelopmentServer = DebugUtils.IsDebugBuild; @@ -184,10 +185,7 @@ namespace osu.Game dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage)); - dependencies.Cache(realmFactory = new RealmContextFactory(Storage)); - - updateThreadState = Host.UpdateThread.State.GetBoundCopy(); - updateThreadState.BindValueChanged(updateThreadStateChanged); + dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client")); AddInternal(realmFactory); @@ -200,31 +198,7 @@ namespace osu.Game dependencies.CacheAs(this); dependencies.CacheAs(LocalConfig); - AddFont(Resources, @"Fonts/osuFont"); - - 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/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/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"); + InitialiseFonts(); Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; @@ -262,8 +236,8 @@ namespace osu.Game dependencies.Cache(fileStore = new FileStore(contextFactory, Storage)); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() - dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host, () => difficultyCache, LocalConfig)); - dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, true)); + dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Scheduler, Host, () => difficultyCache, LocalConfig)); + dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, performOnlineLookups: true)); // 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 @@ -271,7 +245,7 @@ namespace osu.Game List getBeatmapScores(BeatmapSetInfo set) { var beatmapIds = BeatmapManager.QueryBeatmaps(b => b.BeatmapSetInfoID == set.ID).Select(b => b.ID).ToList(); - return ScoreManager.QueryScores(s => beatmapIds.Contains(s.Beatmap.ID)).ToList(); + return ScoreManager.QueryScores(s => beatmapIds.Contains(s.BeatmapInfo.ID)).ToList(); } BeatmapManager.ItemRemoved.BindValueChanged(i => @@ -298,8 +272,7 @@ namespace osu.Game migrateDataToRealm(); - dependencies.Cache(settingsStore = new SettingsStore(contextFactory)); - dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(settingsStore)); + dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(realmFactory, RulesetStore)); var powerStatus = CreateBatteryInfo(); if (powerStatus != null) @@ -346,10 +319,7 @@ namespace osu.Game base.Content.Add(CreateScalingContainer().WithChildren(mainContent)); KeyBindingStore = new RealmKeyBindingStore(realmFactory); - KeyBindingStore.Register(globalBindings); - - foreach (var r in RulesetStore.AvailableRulesets) - KeyBindingStore.Register(r); + KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); dependencies.Cache(globalBindings); @@ -363,21 +333,38 @@ namespace osu.Game Ruleset.BindValueChanged(onRulesetChanged); } - private IDisposable blocking; - - private void updateThreadStateChanged(ValueChangedEvent state) + protected virtual void InitialiseFonts() { - switch (state.NewValue) - { - case GameThreadState.Running: - blocking?.Dispose(); - blocking = null; - break; + AddFont(Resources, @"Fonts/osuFont"); - case GameThreadState.Paused: - blocking = realmFactory.BlockAllOperations(); - break; - } + 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/Torus-Alternate/Torus-Alternate-Regular"); + AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-Light"); + AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-SemiBold"); + AddFont(Resources, @"Fonts/Torus-Alternate/Torus-Alternate-Bold"); + + 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/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"); } protected override void LoadComplete() @@ -424,11 +411,28 @@ namespace osu.Game { Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); - using (realmFactory.BlockAllOperations()) + IDisposable realmBlocker = null; + + try { - contextFactory.FlushConnections(); + ManualResetEventSlim readyToRun = new ManualResetEventSlim(); + + Scheduler.Add(() => + { + realmBlocker = realmFactory.BlockAllOperations(); + contextFactory.FlushConnections(); + + readyToRun.Set(); + }, false); + + readyToRun.Wait(); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); } + finally + { + realmBlocker?.Dispose(); + } Logger.Log(@"Migration complete!"); } @@ -444,40 +448,48 @@ namespace osu.Game private void migrateDataToRealm() { using (var db = contextFactory.GetForWrite()) - using (var usage = realmFactory.GetForWrite()) + using (var realm = realmFactory.CreateContext()) + using (var transaction = realm.BeginWrite()) { - var existingBindings = db.Context.DatabasedKeyBinding; + // migrate ruleset settings. can be removed 20220315. + var existingSettings = db.Context.DatabasedSetting; // only migrate data if the realm database is empty. - if (!usage.Realm.All().Any()) + if (!realm.All().Any()) { - foreach (var dkb in existingBindings) + foreach (var dkb in existingSettings) { - usage.Realm.Add(new RealmKeyBinding + if (dkb.RulesetID == null) continue; + + realm.Add(new RealmRulesetSetting { - KeyCombinationString = dkb.KeyCombination.ToString(), - ActionInt = (int)dkb.Action, - RulesetID = dkb.RulesetID, - Variant = dkb.Variant + Key = dkb.Key, + Value = dkb.StringValue, + RulesetID = dkb.RulesetID.Value, + Variant = dkb.Variant ?? 0, }); } } - db.Context.RemoveRange(existingBindings); + db.Context.RemoveRange(existingSettings); - usage.Commit(); + transaction.Commit(); } } private void onRulesetChanged(ValueChangedEvent r) { + if (r.NewValue?.Available != true) + { + // reject the change if the ruleset is not available. + Ruleset.Value = r.OldValue?.Available == true ? r.OldValue : RulesetStore.AvailableRulesets.First(); + return; + } + var dict = new Dictionary>(); - if (r.NewValue?.Available == true) - { - foreach (ModType type in Enum.GetValues(typeof(ModType))) - dict[type] = r.NewValue.CreateInstance().GetModsFor(type).ToList(); - } + foreach (ModType type in Enum.GetValues(typeof(ModType))) + dict[type] = r.NewValue.CreateInstance().GetModsFor(type).ToList(); if (!SelectedMods.Disabled) SelectedMods.Value = Array.Empty(); @@ -516,7 +528,7 @@ namespace osu.Game BeatmapManager?.Dispose(); LocalConfig?.Dispose(); - contextFactory.FlushConnections(); + contextFactory?.FlushConnections(); } } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 650d105911..03d36ff5df 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -208,7 +208,8 @@ namespace osu.Game.Overlays.BeatmapListing { var sets = response.BeatmapSets.Select(responseJson => responseJson.ToBeatmapSet(rulesets)).ToList(); - if (sets.Count == 0) + // If the previous request returned a null cursor, the API is indicating we can't paginate further (maybe there are no more beatmaps left). + if (sets.Count == 0 || response.Cursor == null) noMoreResults = true; if (CurrentPage == 0) diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs index 6a9a71210a..3568fe9e4f 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.BeatmapListing { public class BeatmapListingHeader : OverlayHeader @@ -11,8 +14,8 @@ namespace osu.Game.Overlays.BeatmapListing { public BeatmapListingTitle() { - Title = "beatmap listing"; - Description = "browse for new beatmaps"; + Title = PageTitleStrings.MainBeatmapsetsControllerIndex; + Description = NamedOverlayComponentStrings.BeatmapListingDescription; IconTexture = "Icons/Hexacons/beatmap"; } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index 0626f236b8..bbde03aa10 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -3,21 +3,22 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osuTK; -using osu.Framework.Bindables; using osu.Framework.Input.Events; -using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Resources.Localisation.Web; -using osuTK.Graphics; using osu.Game.Rulesets; using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapListing { @@ -117,7 +118,7 @@ namespace osu.Game.Overlays.BeatmapListing textBox = new BeatmapSearchTextBox { RelativeSizeAxes = Axes.X, - TypingStarted = () => TypingStarted?.Invoke(), + TextChanged = () => TypingStarted?.Invoke(), }, new ReverseChildIDFillFlowContainer { @@ -127,7 +128,7 @@ namespace osu.Game.Overlays.BeatmapListing Padding = new MarginPadding { Horizontal = 10 }, Children = new Drawable[] { - generalFilter = new BeatmapSearchMultipleSelectionFilterRow(BeatmapsStrings.ListingSearchFiltersGeneral), + generalFilter = new BeatmapSearchGeneralFilterRow(), modeFilter = new BeatmapSearchRulesetFilterRow(), categoryFilter = new BeatmapSearchFilterRow(BeatmapsStrings.ListingSearchFiltersStatus), genreFilter = new BeatmapSearchFilterRow(BeatmapsStrings.ListingSearchFiltersGenre), @@ -167,7 +168,7 @@ namespace osu.Game.Overlays.BeatmapListing /// /// Any time the text box receives key events (even while masked). /// - public Action TypingStarted; + public Action TextChanged; protected override Color4 SelectionColour => Color4.Gray; @@ -181,7 +182,16 @@ namespace osu.Game.Overlays.BeatmapListing if (!base.OnKeyDown(e)) return false; - TypingStarted?.Invoke(); + TextChanged?.Invoke(); + return true; + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (!base.OnPressed(e)) + return false; + + TextChanged?.Invoke(); return true; } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs new file mode 100644 index 0000000000..fb9e1c0420 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Resources.Localisation.Web; +using osuTK.Graphics; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapSearchGeneralFilterRow : BeatmapSearchMultipleSelectionFilterRow + { + public BeatmapSearchGeneralFilterRow() + : base(BeatmapsStrings.ListingSearchFiltersGeneral) + { + } + + protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new GeneralFilter(); + + private class GeneralFilter : MultipleSelectionFilter + { + protected override MultipleSelectionFilterTabItem CreateTabItem(SearchGeneral value) + { + if (value == SearchGeneral.FeaturedArtists) + return new FeaturedArtistsTabItem(); + + return new MultipleSelectionFilterTabItem(value); + } + } + + private class FeaturedArtistsTabItem : MultipleSelectionFilterTabItem + { + public FeaturedArtistsTabItem() + : base(SearchGeneral.FeaturedArtists) + { + } + + protected override Color4 GetStateColour() => OverlayColourProvider.Orange.Colour1; + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs index 46cb1e822f..9274cf20aa 100644 --- a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs +++ b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs @@ -71,10 +71,10 @@ namespace osu.Game.Overlays.BeatmapListing private void updateState() { - text.FadeColour(IsHovered ? colourProvider.Light1 : getStateColour(), 200, Easing.OutQuint); + text.FadeColour(IsHovered ? colourProvider.Light1 : GetStateColour(), 200, Easing.OutQuint); text.Font = text.Font.With(weight: Active.Value ? FontWeight.SemiBold : FontWeight.Regular); } - private Color4 getStateColour() => Active.Value ? colourProvider.Content1 : colourProvider.Light2; + protected virtual Color4 GetStateColour() => Active.Value ? colourProvider.Content1 : colourProvider.Light2; } } diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs index afb5eeda36..9ff39ce1dd 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs @@ -50,6 +50,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels protected Action ViewBeatmap; protected BeatmapPanel(BeatmapSetInfo setInfo) + : base(HoverSampleSet.Submit) { Debug.Assert(setInfo.OnlineBeatmapSetID != null); diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs index cec1a5ac12..47b477ef9a 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs @@ -37,6 +37,13 @@ namespace osu.Game.Overlays.BeatmapListing.Panels RelativeSizeAxes = Axes.Both, }, }; + + button.Add(new DownloadProgressBar(beatmapSet) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Depth = -1, + }); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs index 4d5c387c4a..c078127353 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels private const float horizontal_padding = 10; private const float vertical_padding = 5; - private FillFlowContainer bottomPanel, statusContainer, titleContainer; + private FillFlowContainer bottomPanel, statusContainer, titleContainer, artistContainer; private PlayButton playButton; private Box progressBar; @@ -89,11 +89,19 @@ namespace osu.Game.Overlays.BeatmapListing.Panels }, } }, - new OsuSpriteText + artistContainer = new FillFlowContainer { - Text = new RomanisableString(SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist), - Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true) - }, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = new RomanisableString(SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist), + Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true) + } + } + } }, }, new Container @@ -213,6 +221,16 @@ namespace osu.Game.Overlays.BeatmapListing.Panels }); } + if (SetInfo.OnlineInfo?.TrackId != null) + { + artistContainer.Add(new FeaturedArtistBeatmapPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 10f, Top = 2f }, + }); + } + if (SetInfo.OnlineInfo?.HasVideo ?? false) { statusContainer.Add(new IconPill(FontAwesome.Solid.Film)); diff --git a/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs index 00ffd168c1..5011749c5f 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs @@ -27,7 +27,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels private const float vertical_padding = 5; private const float height = 70; - private FillFlowContainer statusContainer, titleContainer; + private FillFlowContainer statusContainer, titleContainer, artistContainer; protected BeatmapPanelDownloadButton DownloadButton; private PlayButton playButton; private Box progressBar; @@ -112,10 +112,18 @@ namespace osu.Game.Overlays.BeatmapListing.Panels }, } }, - new OsuSpriteText + artistContainer = new FillFlowContainer { - Text = new RomanisableString(SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist), - Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true) + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new[] + { + new OsuSpriteText + { + Text = new RomanisableString(SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist), + Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true) + }, + }, }, } }, @@ -227,6 +235,16 @@ namespace osu.Game.Overlays.BeatmapListing.Panels }); } + if (SetInfo.OnlineInfo?.TrackId != null) + { + artistContainer.Add(new FeaturedArtistBeatmapPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 10f, Top = 2f }, + }); + } + if (SetInfo.OnlineInfo?.HasVideo ?? false) { statusContainer.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) }); diff --git a/osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs index 4bbc3569fe..3aa9aa5ca5 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs @@ -139,19 +139,24 @@ namespace osu.Game.Overlays.BeatmapListing.Panels LoadComponentAsync(Preview = previewTrackManager.Get(beatmapSet), preview => { - // beatmapset may have changed. - if (Preview != preview) - return; + // Make sure that we schedule to after the next audio frame to fix crashes in single-threaded execution. + // See: https://github.com/ppy/osu-framework/issues/4692 + Schedule(() => + { + // beatmapset may have changed. + if (Preview != preview) + return; - AddInternal(preview); - loading = false; - // make sure that the update of value of Playing (and the ensuing value change callbacks) - // are marshaled back to the update thread. - preview.Stopped += () => Schedule(() => playing.Value = false); + AddInternal(preview); + loading = false; + // make sure that the update of value of Playing (and the ensuing value change callbacks) + // are marshaled back to the update thread. + preview.Stopped += () => Schedule(() => playing.Value = false); - // user may have changed their mind. - if (playing.Value) - attemptStart(); + // user may have changed their mind. + if (playing.Value) + attemptStart(); + }); }); } else diff --git a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs index 8a9df76af3..d6ae41aba1 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs @@ -1,69 +1,42 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchCategoryEnumLocalisationMapper))] public enum SearchCategory { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusAny))] Any, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusLeaderboard))] [Description("Has Leaderboard")] Leaderboard, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusRanked))] Ranked, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusQualified))] Qualified, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusLoved))] Loved, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusFavourites))] Favourites, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusPending))] [Description("Pending & WIP")] Pending, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusGraveyard))] Graveyard, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusMine))] [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 78e6a4e094..80482b32a0 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs @@ -1,34 +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 osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchExplicitEnumLocalisationMapper))] public enum SearchExplicit { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.NsfwExclude))] Hide, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.NsfwInclude))] 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 4b3fb6e833..e54632acd8 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchExtra.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchExtra.cs @@ -1,38 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchExtraEnumLocalisationMapper))] public enum SearchExtra { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ExtraVideo))] [Description("Has Video")] Video, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ExtraStoryboard))] [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 b4c629f7fa..9387020bdf 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs @@ -1,44 +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 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 { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GeneralRecommended))] [Description("Recommended difficulty")] Recommended, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GeneralConverts))] [Description("Include converted beatmaps")] Converts, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GeneralFollows))] [Description("Subscribed mappers")] - Follows - } + 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); - } - } + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GeneralFeaturedArtists))] + [Description("Featured artists")] + FeaturedArtists } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs index b2709ecd2e..08855284cb 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs @@ -1,87 +1,56 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchGenreEnumLocalisationMapper))] public enum SearchGenre { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreAny))] Any = 0, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreUnspecified))] Unspecified = 1, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreVideoGame))] [Description("Video Game")] VideoGame = 2, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreAnime))] Anime = 3, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreRock))] Rock = 4, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenrePop))] Pop = 5, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreOther))] Other = 6, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreNovelty))] Novelty = 7, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreHipHop))] [Description("Hip Hop")] HipHop = 9, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreElectronic))] Electronic = 10, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreMetal))] Metal = 11, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreClassical))] Classical = 12, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreFolk))] Folk = 13, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreJazz))] 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 fc176c305a..7ffa0282b7 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs @@ -1,117 +1,73 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchLanguageEnumLocalisationMapper))] [HasOrderedElements] public enum SearchLanguage { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageAny))] [Order(0)] Any, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageUnspecified))] [Order(14)] Unspecified, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageEnglish))] [Order(1)] English, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageJapanese))] [Order(6)] Japanese, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageChinese))] [Order(2)] Chinese, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageInstrumental))] [Order(12)] Instrumental, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageKorean))] [Order(7)] Korean, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageFrench))] [Order(3)] French, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageGerman))] [Order(4)] German, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageSwedish))] [Order(9)] Swedish, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageSpanish))] [Order(8)] Spanish, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageItalian))] [Order(5)] Italian, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageRussian))] [Order(10)] Russian, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguagePolish))] [Order(11)] Polish, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageOther))] [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 f24cf46c2d..3b04ac01ca 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs @@ -1,38 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchPlayedEnumLocalisationMapper))] public enum SearchPlayed { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.PlayedAny))] Any, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.PlayedPlayed))] Played, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.PlayedUnplayed))] 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 5ea885eecc..871b3c162b 100644 --- a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs +++ b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs @@ -1,58 +1,35 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SortCriteriaLocalisationMapper))] public enum SortCriteria { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingTitle))] Title, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingArtist))] Artist, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingDifficulty))] Difficulty, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRanked))] Ranked, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRating))] Rating, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingPlays))] Plays, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingFavourites))] Favourites, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRelevance))] 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 6861d17f26..935a89b99b 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -75,6 +75,7 @@ namespace osu.Game.Overlays { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, + Masking = true, Padding = new MarginPadding { Horizontal = 20 }, Children = new Drawable[] { @@ -186,21 +187,16 @@ namespace osu.Game.Overlays if (lastContent != null) { - var transform = lastContent.FadeOut(100, Easing.OutQuint); + lastContent.FadeOut(100, Easing.OutQuint); - if (lastContent == notFoundContent || lastContent == supporterRequiredContent) - { - // the placeholders may be used multiple times, so don't expire/dispose them. - transform.Schedule(() => panelTarget.Remove(lastContent)); - } - else - { - // Consider the case when the new content is smaller than the last content. - // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird. - // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0. - // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so. - lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => lastContent.Expire()); - } + // Consider the case when the new content is smaller than the last content. + // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird. + // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0. + // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so. + var sequence = lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y); + + if (lastContent != notFoundContent && lastContent != supporterRequiredContent) + sequence.Then().Schedule(() => lastContent.Expire()); } if (!content.IsAlive) @@ -208,6 +204,9 @@ namespace osu.Game.Overlays content.FadeInFromZero(200, Easing.OutQuint); currentContent = content; + // currentContent may be one of the placeholders, and still have BypassAutoSizeAxes set to Y from the last fade-out. + // restore to the initial state. + currentContent.BypassAutoSizeAxes = Axes.None; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index b81c60a5b9..683f4f0c49 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -4,14 +4,17 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Overlays.BeatmapSet @@ -35,16 +38,16 @@ namespace osu.Game.Overlays.BeatmapSet } } - private BeatmapInfo beatmap; + private BeatmapInfo beatmapInfo; - public BeatmapInfo Beatmap + public BeatmapInfo BeatmapInfo { - get => beatmap; + get => beatmapInfo; set { - if (value == beatmap) return; + if (value == beatmapInfo) return; - beatmap = value; + beatmapInfo = value; updateDisplay(); } @@ -52,9 +55,9 @@ namespace osu.Game.Overlays.BeatmapSet private void updateDisplay() { - bpm.Value = BeatmapSet?.OnlineInfo?.BPM.ToString(@"0.##") ?? "-"; + bpm.Value = BeatmapSet?.OnlineInfo?.BPM.ToLocalisableString(@"0.##") ?? (LocalisableString)"-"; - if (beatmap == null) + if (beatmapInfo == null) { length.Value = string.Empty; circleCount.Value = string.Empty; @@ -62,9 +65,11 @@ namespace osu.Game.Overlays.BeatmapSet } else { - length.Value = TimeSpan.FromMilliseconds(beatmap.Length).ToString(@"m\:ss"); - circleCount.Value = beatmap.OnlineInfo.CircleCount.ToString(); - sliderCount.Value = beatmap.OnlineInfo.SliderCount.ToString(); + length.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(TimeSpan.FromMilliseconds(beatmapInfo.Length).ToFormattedDuration()); + length.Value = TimeSpan.FromMilliseconds(beatmapInfo.Length).ToFormattedDuration(); + + circleCount.Value = beatmapInfo.OnlineInfo.CircleCount.ToLocalisableString(@"N0"); + sliderCount.Value = beatmapInfo.OnlineInfo.SliderCount.ToLocalisableString(@"N0"); } } @@ -77,10 +82,26 @@ namespace osu.Game.Overlays.BeatmapSet Direction = FillDirection.Horizontal, Children = new[] { - length = new Statistic(FontAwesome.Regular.Clock, "Length") { Width = 0.25f }, - bpm = new Statistic(FontAwesome.Regular.Circle, "BPM") { Width = 0.25f }, - circleCount = new Statistic(FontAwesome.Regular.Circle, "Circle Count") { Width = 0.25f }, - sliderCount = new Statistic(FontAwesome.Regular.Circle, "Slider Count") { Width = 0.25f }, + length = new Statistic(BeatmapStatisticsIconType.Length) + { + Width = 0.25f, + TooltipText = default, + }, + bpm = new Statistic(BeatmapStatisticsIconType.Bpm) + { + Width = 0.25f, + TooltipText = BeatmapsetsStrings.ShowStatsBpm + }, + circleCount = new Statistic(BeatmapStatisticsIconType.Circles) + { + Width = 0.25f, + TooltipText = BeatmapsetsStrings.ShowStatsCountCircles + }, + sliderCount = new Statistic(BeatmapStatisticsIconType.Sliders) + { + Width = 0.25f, + TooltipText = BeatmapsetsStrings.ShowStatsCountSliders + }, }, }; } @@ -95,7 +116,7 @@ namespace osu.Game.Overlays.BeatmapSet { private readonly OsuSpriteText value; - public LocalisableString TooltipText { get; } + public LocalisableString TooltipText { get; set; } public LocalisableString Value { @@ -103,9 +124,8 @@ namespace osu.Game.Overlays.BeatmapSet set => this.value.Text = value; } - public Statistic(IconUsage icon, string name) + public Statistic(BeatmapStatisticsIconType icon) { - TooltipText = name; RelativeSizeAxes = Axes.X; Height = 24f; @@ -132,8 +152,16 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.CentreLeft, Origin = Anchor.Centre, - Icon = icon, - Size = new Vector2(12), + Icon = FontAwesome.Regular.Circle, + Size = new Vector2(10), + Rotation = 0, + Colour = Color4Extensions.FromHex(@"f7dd55"), + }, + new BeatmapStatisticIcon(icon) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Size = new Vector2(10), Colour = Color4Extensions.FromHex(@"f7dd55"), Scale = new Vector2(0.8f), }, diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index 66886b0882..3df275c6d3 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -16,6 +17,7 @@ using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osuTK; @@ -26,7 +28,8 @@ namespace osu.Game.Overlays.BeatmapSet private const float tile_icon_padding = 7; private const float tile_spacing = 2; - private readonly OsuSpriteText version, starRating; + private readonly OsuSpriteText version, starRating, starRatingText; + private readonly FillFlowContainer starRatingContainer; private readonly Statistic plays, favourites; public readonly DifficultiesContainer Difficulties; @@ -68,14 +71,14 @@ namespace osu.Game.Overlays.BeatmapSet OnLostHover = () => { showBeatmap(Beatmap.Value); - starRating.FadeOut(100); + starRatingContainer.FadeOut(100); }, }, new FillFlowContainer { AutoSizeAxes = Axes.Both, Spacing = new Vector2(5f), - Children = new[] + Children = new Drawable[] { version = new OsuSpriteText { @@ -83,14 +86,31 @@ namespace osu.Game.Overlays.BeatmapSet Origin = Anchor.BottomLeft, Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold) }, - starRating = new OsuSpriteText + starRatingContainer = new FillFlowContainer { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold), - Text = "Star Difficulty", Alpha = 0, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2f, 0), Margin = new MarginPadding { Bottom = 1 }, + Children = new[] + { + starRatingText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold), + Text = BeatmapsetsStrings.ShowStatsStars, + }, + starRating = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold), + Text = string.Empty, + }, + } }, }, }, @@ -124,6 +144,7 @@ namespace osu.Game.Overlays.BeatmapSet private void load(OsuColour colours) { starRating.Colour = colours.Yellow; + starRatingText.Colour = colours.Yellow; updateDisplay(); } @@ -149,29 +170,29 @@ namespace osu.Game.Overlays.BeatmapSet OnHovered = beatmap => { showBeatmap(beatmap); - starRating.Text = beatmap.StarDifficulty.ToString("Star Difficulty 0.##"); - starRating.FadeIn(100); + starRating.Text = beatmap.StarDifficulty.ToLocalisableString(@"0.##"); + starRatingContainer.FadeIn(100); }, OnClicked = beatmap => { Beatmap.Value = beatmap; }, }); } - starRating.FadeOut(100); - Beatmap.Value = Difficulties.FirstOrDefault()?.Beatmap; + starRatingContainer.FadeOut(100); + Beatmap.Value = Difficulties.FirstOrDefault()?.BeatmapInfo; plays.Value = BeatmapSet?.OnlineInfo.PlayCount ?? 0; favourites.Value = BeatmapSet?.OnlineInfo.FavouriteCount ?? 0; updateDifficultyButtons(); } - private void showBeatmap(BeatmapInfo beatmap) + private void showBeatmap(BeatmapInfo beatmapInfo) { - version.Text = beatmap?.Version; + version.Text = beatmapInfo?.Version; } private void updateDifficultyButtons() { - Difficulties.Children.ToList().ForEach(diff => diff.State = diff.Beatmap == Beatmap.Value ? DifficultySelectorState.Selected : DifficultySelectorState.NotSelected); + Difficulties.Children.ToList().ForEach(diff => diff.State = diff.BeatmapInfo == Beatmap.Value ? DifficultySelectorState.Selected : DifficultySelectorState.NotSelected); } public class DifficultiesContainer : FillFlowContainer @@ -195,7 +216,7 @@ namespace osu.Game.Overlays.BeatmapSet private readonly Box backgroundBox; private readonly DifficultyIcon icon; - public readonly BeatmapInfo Beatmap; + public readonly BeatmapInfo BeatmapInfo; public Action OnHovered; public Action OnClicked; @@ -220,9 +241,9 @@ namespace osu.Game.Overlays.BeatmapSet } } - public DifficultySelectorButton(BeatmapInfo beatmap) + public DifficultySelectorButton(BeatmapInfo beatmapInfo) { - Beatmap = beatmap; + BeatmapInfo = beatmapInfo; Size = new Vector2(size); Margin = new MarginPadding { Horizontal = tile_spacing / 2 }; @@ -239,7 +260,7 @@ namespace osu.Game.Overlays.BeatmapSet Alpha = 0.5f } }, - icon = new DifficultyIcon(beatmap, shouldShowTooltip: false) + icon = new DifficultyIcon(beatmapInfo, shouldShowTooltip: false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -252,7 +273,7 @@ namespace osu.Game.Overlays.BeatmapSet protected override bool OnHover(HoverEvent e) { fadeIn(); - OnHovered?.Invoke(Beatmap); + OnHovered?.Invoke(BeatmapInfo); return base.OnHover(e); } @@ -265,7 +286,7 @@ namespace osu.Game.Overlays.BeatmapSet protected override bool OnClick(ClickEvent e) { - OnClicked?.Invoke(Beatmap); + OnClicked?.Invoke(BeatmapInfo); return base.OnClick(e); } @@ -300,7 +321,7 @@ namespace osu.Game.Overlays.BeatmapSet set { this.value = value; - text.Text = Value.ToString(@"N0"); + text.Text = Value.ToLocalisableString(@"N0"); } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs index 4b26b02a8e..4a0c0e9f75 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs @@ -7,6 +7,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; using osu.Game.Beatmaps; +using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -54,7 +55,7 @@ namespace osu.Game.Overlays.BeatmapSet { public BeatmapHeaderTitle() { - Title = "beatmap info"; + Title = PageTitleStrings.MainBeatmapsetsControllerShow; IconTexture = "Icons/Hexacons/beatmap"; } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index a61640a02e..dcf06ac7fb 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -37,6 +37,7 @@ namespace osu.Game.Overlays.BeatmapSet private readonly OsuSpriteText title, artist; private readonly AuthorInfo author; private readonly ExplicitContentBeatmapPill explicitContentPill; + private readonly FeaturedArtistBeatmapPill featuredArtistPill; private readonly FillFlowContainer downloadButtonsContainer; private readonly BeatmapAvailability beatmapAvailability; private readonly BeatmapSetOnlineStatusPill onlineStatusPill; @@ -129,10 +130,25 @@ namespace osu.Game.Overlays.BeatmapSet } } }, - artist = new OsuSpriteText + new FillFlowContainer { - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, italics: true), - Margin = new MarginPadding { Bottom = 20 } + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Bottom = 20 }, + Children = new Drawable[] + { + artist = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, italics: true), + }, + featuredArtistPill = new FeaturedArtistBeatmapPill + { + Alpha = 0f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Left = 10 } + } + } }, new Container { @@ -195,7 +211,7 @@ namespace osu.Game.Overlays.BeatmapSet Picker.Beatmap.ValueChanged += b => { - Details.Beatmap = b.NewValue; + Details.BeatmapInfo = b.NewValue; externalLink.Link = $@"{api.WebsiteRootUrl}/beatmapsets/{BeatmapSet.Value?.OnlineBeatmapSetID}#{b.NewValue?.Ruleset.ShortName}/{b.NewValue?.OnlineBeatmapID}"; }; } @@ -233,6 +249,7 @@ namespace osu.Game.Overlays.BeatmapSet artist.Text = new RomanisableString(setInfo.NewValue.Metadata.ArtistUnicode, setInfo.NewValue.Metadata.Artist); explicitContentPill.Alpha = setInfo.NewValue.OnlineInfo.HasExplicitContent ? 1 : 0; + featuredArtistPill.Alpha = setInfo.NewValue.OnlineInfo.TrackId != null ? 1 : 0; onlineStatusPill.FadeIn(500, Easing.OutQuint); onlineStatusPill.Status = setInfo.NewValue.OnlineInfo.Status; diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs index bb87e7151b..43dd1438f1 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Notifications; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -35,7 +36,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { if (!Enabled.Value) return string.Empty; - return (favourited.Value ? "Unfavourite" : "Favourite") + " this beatmapset"; + return favourited.Value ? BeatmapsetsStrings.ShowDetailsUnfavourite : BeatmapsetsStrings.ShowDetailsFavourite; } } diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs index cef623e59b..e7a55079ec 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Overlays.BeatmapListing.Panels; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; using osuTK.Graphics; @@ -27,7 +28,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private readonly bool noVideo; - public LocalisableString TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download"; + public LocalisableString TooltipText => BeatmapsetsStrings.ShowDetailsDownloadDefault; private readonly IBindable localUser = new Bindable(); @@ -113,7 +114,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { new OsuSpriteText { - Text = "Downloading...", + Text = Localisation.CommonStrings.Downloading, Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, }; @@ -124,7 +125,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { new OsuSpriteText { - Text = "Importing...", + Text = Localisation.CommonStrings.Importing, Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, }; @@ -139,7 +140,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { new OsuSpriteText { - Text = "Download", + Text = BeatmapsetsStrings.ShowDetailsDownloadDefault, Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, new OsuSpriteText @@ -158,12 +159,12 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private void enabledChanged(ValueChangedEvent e) => this.FadeColour(e.NewValue ? Color4.White : Color4.Gray, 200, Easing.OutQuint); - private string getVideoSuffixText() + private LocalisableString getVideoSuffixText() { if (!BeatmapSet.Value.OnlineInfo.HasVideo) return string.Empty; - return noVideo ? "without Video" : "with Video"; + return noVideo ? BeatmapsetsStrings.ShowDetailsDownloadNoVideo : BeatmapsetsStrings.ShowDetailsDownloadVideo; } } } diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs index a5e5f664c9..5b3c142a66 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs @@ -63,7 +63,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons }, }; - Action = () => playButton.Click(); + Action = () => playButton.TriggerClick(); Playing.ValueChanged += playing => progress.FadeTo(playing.NewValue ? 1 : 0, 100); } diff --git a/osu.Game/Overlays/BeatmapSet/Details.cs b/osu.Game/Overlays/BeatmapSet/Details.cs index 680487ffbb..92361ae4f8 100644 --- a/osu.Game/Overlays/BeatmapSet/Details.cs +++ b/osu.Game/Overlays/BeatmapSet/Details.cs @@ -37,16 +37,16 @@ namespace osu.Game.Overlays.BeatmapSet } } - private BeatmapInfo beatmap; + private BeatmapInfo beatmapInfo; - public BeatmapInfo Beatmap + public BeatmapInfo BeatmapInfo { - get => beatmap; + get => beatmapInfo; set { - if (value == beatmap) return; + if (value == beatmapInfo) return; - basic.Beatmap = advanced.Beatmap = beatmap = value; + basic.BeatmapInfo = advanced.BeatmapInfo = beatmapInfo = value; } } diff --git a/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapPill.cs b/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapPill.cs index 329f8ee0a2..ba78592ed2 100644 --- a/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapPill.cs +++ b/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapPill.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapSet { @@ -34,7 +36,7 @@ namespace osu.Game.Overlays.BeatmapSet new OsuSpriteText { Margin = new MarginPadding { Horizontal = 10f, Vertical = 2f }, - Text = "EXPLICIT", + Text = BeatmapsetsStrings.NsfwBadgeLabel.ToUpper(), Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), Colour = OverlayColourProvider.Orange.Colour2, } diff --git a/osu.Game/Overlays/BeatmapSet/FeaturedArtistBeatmapPill.cs b/osu.Game/Overlays/BeatmapSet/FeaturedArtistBeatmapPill.cs new file mode 100644 index 0000000000..fdee0799ff --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/FeaturedArtistBeatmapPill.cs @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class FeaturedArtistBeatmapPill : CompositeDrawable + { + public FeaturedArtistBeatmapPill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader(true)] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + InternalChild = new CircularContainer + { + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider?.Background5 ?? colours.Gray2, + }, + new OsuSpriteText + { + Margin = new MarginPadding { Horizontal = 10f, Vertical = 2f }, + Text = BeatmapsetsStrings.FeaturedArtistBadgeLabel.ToUpper(), + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Colour = OverlayColourProvider.Blue.Colour1, + } + } + }; + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index f9b8de9dba..61c660cbaa 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -24,10 +24,10 @@ namespace osu.Game.Overlays.BeatmapSet public readonly Bindable BeatmapSet = new Bindable(); - public BeatmapInfo Beatmap + public BeatmapInfo BeatmapInfo { - get => successRate.Beatmap; - set => successRate.Beatmap = value; + get => successRate.BeatmapInfo; + set => successRate.BeatmapInfo = value; } public Info() diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs index 98662e5dea..6349f115cb 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs @@ -19,19 +19,21 @@ namespace osu.Game.Overlays.BeatmapSet { public class LeaderboardModSelector : CompositeDrawable { - public readonly BindableList SelectedMods = new BindableList(); + public readonly BindableList SelectedMods = new BindableList(); public readonly Bindable Ruleset = new Bindable(); private readonly FillFlowContainer modsContainer; public LeaderboardModSelector() { - AutoSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; InternalChild = modsContainer = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Direction = FillDirection.Full, Spacing = new Vector2(4), }; @@ -52,9 +54,14 @@ namespace osu.Game.Overlays.BeatmapSet return; modsContainer.Add(new ModButton(new ModNoMod())); - modsContainer.AddRange(ruleset.NewValue.CreateInstance().GetAllMods().Where(m => m.UserPlayable).Select(m => new ModButton(m))); + modsContainer.AddRange(ruleset.NewValue.CreateInstance().AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m))); - modsContainer.ForEach(button => button.OnSelectionChanged = selectionChanged); + modsContainer.ForEach(button => + { + button.Anchor = Anchor.TopCentre; + button.Origin = Anchor.TopCentre; + button.OnSelectionChanged = selectionChanged; + }); } protected override bool OnHover(HoverEvent e) @@ -69,7 +76,7 @@ namespace osu.Game.Overlays.BeatmapSet updateHighlighted(); } - private void selectionChanged(Mod mod, bool selected) + private void selectionChanged(IMod mod, bool selected) { if (selected) SelectedMods.Add(mod); @@ -94,9 +101,9 @@ namespace osu.Game.Overlays.BeatmapSet private const int duration = 200; public readonly BindableBool Highlighted = new BindableBool(); - public Action OnSelectionChanged; + public Action OnSelectionChanged; - public ModButton(Mod mod) + public ModButton(IMod mod) : base(mod) { Scale = new Vector2(0.4f); diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSection.cs b/osu.Game/Overlays/BeatmapSet/MetadataSection.cs index 3648c55714..f1b9f98528 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataSection.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataSection.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.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -46,7 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet AutoSizeAxes = Axes.Y, Child = new OsuSpriteText { - Text = this.type.ToString(), + Text = this.type.GetLocalisableDescription(), Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), }, }, diff --git a/osu.Game/Overlays/BeatmapSet/MetadataType.cs b/osu.Game/Overlays/BeatmapSet/MetadataType.cs index 1ab4c6887e..f992dca0ef 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataType.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataType.cs @@ -1,14 +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.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.BeatmapSet { public enum MetadataType { + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoTags))] Tags, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoSource))] Source, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoDescription))] Description, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoGenre))] Genre, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoLanguage))] Language } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs index 391ba93a4b..82ca3e030f 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Screens.Select.Leaderboards; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapSet.Scores { @@ -30,15 +31,15 @@ namespace osu.Game.Overlays.BeatmapSet.Scores switch (scope) { default: - text.Text = @"No scores have been set yet. Maybe you can be the first!"; + text.Text = BeatmapsetsStrings.ShowScoreboardNoScoresGlobal; break; case BeatmapLeaderboardScope.Friend: - text.Text = @"None of your friends have set a score on this map yet."; + text.Text = BeatmapsetsStrings.ShowScoreboardNoScoresFriend; break; case BeatmapLeaderboardScope.Country: - text.Text = @"No one from your country has set a score on this map yet."; + text.Text = BeatmapsetsStrings.ShowScoreboardNoScoresCountry; break; } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index ddd1dfa6cd..018faf2011 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -18,6 +19,9 @@ using osu.Game.Scoring; using osu.Game.Users.Drawables; using osuTK; using osuTK.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapSet.Scores { @@ -91,13 +95,13 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { var columns = new List { - new TableColumn("rank", Anchor.CentreRight, new Dimension(GridSizeMode.AutoSize)), + new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersRank, Anchor.CentreRight, new Dimension(GridSizeMode.AutoSize)), new TableColumn("", Anchor.Centre, new Dimension(GridSizeMode.Absolute, 70)), // grade - new TableColumn("score", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("accuracy", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, minSize: 60, maxSize: 70)), + new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersScore, Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), + new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, minSize: 60, maxSize: 70)), new TableColumn("", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 25)), // flag - new TableColumn("player", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 125)), - new TableColumn("max combo", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 70, maxSize: 120)) + new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersPlayer, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 125)), + new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersCombo, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 70, maxSize: 120)) }; // All statistics across all scores, unordered. @@ -122,9 +126,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } if (showPerformancePoints) - columns.Add(new TableColumn("pp", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 30))); + columns.Add(new TableColumn(BeatmapsetsStrings.ShowScoreboardHeaderspp, Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 30))); - columns.Add(new TableColumn("mods", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize))); + columns.Add(new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersMods, Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize))); return columns.ToArray(); } @@ -138,7 +142,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { new OsuSpriteText { - Text = $"#{index + 1}", + Text = (index + 1).ToLocalisableString(@"\##"), Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, new UpdateableRank(score.Rank) @@ -166,9 +170,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores username, new OsuSpriteText { - Text = $@"{score.MaxCombo:N0}x", + Text = score.MaxCombo.ToLocalisableString(@"0\x"), Font = OsuFont.GetFont(size: text_size), - Colour = score.MaxCombo == score.Beatmap?.MaxCombo ? highAccuracyColour : Color4.White + Colour = score.MaxCombo == score.BeatmapInfo?.MaxCombo ? highAccuracyColour : Color4.White } }; @@ -181,7 +185,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores content.Add(new OsuSpriteText { - Text = stat.MaxCount == null ? $"{stat.Count}" : $"{stat.Count}/{stat.MaxCount}", + Text = stat.MaxCount == null ? stat.Count.ToLocalisableString(@"N0") : (LocalisableString)$"{stat.Count}/{stat.MaxCount}", Font = OsuFont.GetFont(size: text_size), Colour = stat.Count == 0 ? Color4.Gray : Color4.White }); @@ -189,9 +193,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores if (showPerformancePoints) { + Debug.Assert(score.PP != null); + content.Add(new OsuSpriteText { - Text = $@"{score.PP:N0}", + Text = score.PP.ToLocalisableString(@"N0"), Font = OsuFont.GetFont(size: text_size) }); } @@ -211,11 +217,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores return content.ToArray(); } - protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty); + protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? default); private class HeaderText : OsuSpriteText { - public HeaderText(string text) + public HeaderText(LocalisableString text) { Text = text.ToUpper(); Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index aff48919b4..82657afc86 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -7,6 +7,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osuTK; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Game.Online.API.Requests.Responses; using osu.Game.Beatmaps; using osu.Game.Online.API; @@ -14,6 +16,7 @@ using osu.Game.Online.API.Requests; using osu.Framework.Bindables; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; +using osu.Game.Scoring; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; @@ -42,34 +45,46 @@ namespace osu.Game.Overlays.BeatmapSet.Scores [Resolved] private RulesetStore rulesets { get; set; } + [Resolved] + private ScoreManager scoreManager { get; set; } + private GetScoresRequest getScoresRequest; + private CancellationTokenSource loadCancellationSource; + protected APILegacyScores Scores { set => Schedule(() => { + loadCancellationSource?.Cancel(); + loadCancellationSource = new CancellationTokenSource(); + topScoresContainer.Clear(); + scoreTable.ClearScores(); + scoreTable.Hide(); if (value?.Scores.Any() != true) - { - scoreTable.ClearScores(); - scoreTable.Hide(); return; - } - var scoreInfos = value.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToList(); - var topScore = scoreInfos.First(); + scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToArray(), loadCancellationSource.Token) + .ContinueWith(ordered => Schedule(() => + { + if (loadCancellationSource.IsCancellationRequested) + return; - scoreTable.DisplayScores(scoreInfos, topScore.Beatmap?.Status.GrantsPerformancePoints() == true); - scoreTable.Show(); + var topScore = ordered.Result.First(); - var userScore = value.UserScore; - var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets); + scoreTable.DisplayScores(ordered.Result, topScore.BeatmapInfo?.Status.GrantsPerformancePoints() == true); + scoreTable.Show(); - topScoresContainer.Add(new DrawableTopScore(topScore)); + var userScore = value.UserScore; + var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets); - if (userScoreInfo != null && userScoreInfo.OnlineScoreID != topScore.OnlineScoreID) - topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); + topScoresContainer.Add(new DrawableTopScore(topScore)); + + if (userScoreInfo != null && userScoreInfo.OnlineScoreID != topScore.OnlineScoreID) + topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); + }), TaskContinuationOptions.OnlyOnRanToCompletion); }); } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 262f321598..630aa8fe53 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -5,13 +5,16 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Scoring; @@ -60,9 +63,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Spacing = new Vector2(margin, 0), Children = new Drawable[] { - totalScoreColumn = new TextColumn("total score", largeFont, top_columns_min_width), - accuracyColumn = new TextColumn("accuracy", largeFont, top_columns_min_width), - maxComboColumn = new TextColumn("max combo", largeFont, top_columns_min_width) + totalScoreColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersScoreTotal, largeFont, top_columns_min_width), + accuracyColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, largeFont, top_columns_min_width), + maxComboColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersCombo, largeFont, top_columns_min_width) } }, new FillFlowContainer @@ -80,7 +83,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Direction = FillDirection.Horizontal, Spacing = new Vector2(margin, 0), }, - ppColumn = new TextColumn("pp", smallFont, bottom_columns_min_width), + ppColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeaderspp, smallFont, bottom_columns_min_width), modsColumn = new ModsInfoColumn(), } }, @@ -110,10 +113,10 @@ namespace osu.Game.Overlays.BeatmapSet.Scores score = value; accuracyColumn.Text = value.DisplayAccuracy; - maxComboColumn.Text = $@"{value.MaxCombo:N0}x"; + maxComboColumn.Text = value.MaxCombo.ToLocalisableString(@"0\x"); - ppColumn.Alpha = value.Beatmap?.Status.GrantsPerformancePoints() == true ? 1 : 0; - ppColumn.Text = $@"{value.PP:N0}"; + ppColumn.Alpha = value.BeatmapInfo?.Status.GrantsPerformancePoints() == true ? 1 : 0; + ppColumn.Text = value.PP?.ToLocalisableString(@"N0"); statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn); modsColumn.Mods = value.Mods; @@ -125,7 +128,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private TextColumn createStatisticsColumn(HitResultDisplayStatistic stat) => new TextColumn(stat.DisplayName, smallFont, bottom_columns_min_width) { - Text = stat.MaxCount == null ? $"{stat.Count}" : $"{stat.Count}/{stat.MaxCount}" + Text = stat.MaxCount == null ? stat.Count.ToLocalisableString(@"N0") : (LocalisableString)$"{stat.Count}/{stat.MaxCount}" }; private class InfoColumn : CompositeDrawable @@ -133,7 +136,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly Box separator; private readonly OsuSpriteText text; - public InfoColumn(string title, Drawable content, float? minWidth = null) + public InfoColumn(LocalisableString title, Drawable content, float? minWidth = null) { AutoSizeAxes = Axes.Both; Margin = new MarginPadding { Vertical = 5 }; @@ -193,18 +196,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { private readonly SpriteText text; - public TextColumn(string title, FontUsage font, float? minWidth = null) + public TextColumn(LocalisableString title, FontUsage font, float? minWidth = null) : this(title, new OsuSpriteText { Font = font }, minWidth) { } - private TextColumn(string title, SpriteText text, float? minWidth = null) + private TextColumn(LocalisableString title, SpriteText text, float? minWidth = null) : base(title, text, minWidth) { this.text = text; } - public string Text + public LocalisableString Text { set => text.Text = value; } @@ -232,7 +235,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } private ModsInfoColumn(FillFlowContainer modsContainer) - : base("mods", modsContainer) + : base(BeatmapsetsStrings.ShowScoreboardHeadersMods, modsContainer) { this.modsContainer = modsContainer; } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index 736366fb5c..5c3906cb39 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -3,10 +3,12 @@ using System; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -126,7 +128,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public int? ScorePosition { - set => rankText.Text = value == null ? "-" : $"#{value}"; + set => rankText.Text = value?.ToLocalisableString(@"\##") ?? (LocalisableString)"-"; } /// diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs index 3bb36545cd..4a9b8244a5 100644 --- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs +++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osu.Game.Screens.Select.Details; namespace osu.Game.Overlays.BeatmapSet @@ -21,16 +23,16 @@ namespace osu.Game.Overlays.BeatmapSet private readonly Bar successRate; private readonly Container percentContainer; - private BeatmapInfo beatmap; + private BeatmapInfo beatmapInfo; - public BeatmapInfo Beatmap + public BeatmapInfo BeatmapInfo { - get => beatmap; + get => beatmapInfo; set { - if (value == beatmap) return; + if (value == beatmapInfo) return; - beatmap = value; + beatmapInfo = value; updateDisplay(); } @@ -38,15 +40,15 @@ namespace osu.Game.Overlays.BeatmapSet private void updateDisplay() { - int passCount = beatmap?.OnlineInfo?.PassCount ?? 0; - int playCount = beatmap?.OnlineInfo?.PlayCount ?? 0; + int passCount = beatmapInfo?.OnlineInfo?.PassCount ?? 0; + int playCount = beatmapInfo?.OnlineInfo?.PlayCount ?? 0; var rate = playCount != 0 ? (float)passCount / playCount : 0; - successPercent.Text = rate.ToString("0.#%"); + successPercent.Text = rate.ToLocalisableString(@"0.#%"); successRate.Length = rate; percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic); - Graph.Metrics = beatmap?.Metrics; + Graph.Metrics = beatmapInfo?.Metrics; } public SuccessRate() @@ -64,7 +66,7 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "Success Rate", + Text = BeatmapsetsStrings.ShowInfoSuccessRate, Font = OsuFont.GetFont(size: 12) }, successRate = new Bar @@ -89,7 +91,7 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "Points of Failure", + Text = BeatmapsetsStrings.ShowInfoPointsOfFailure, Font = OsuFont.GetFont(size: 12), Margin = new MarginPadding { Vertical = 20 }, }, diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index bdb3715e73..f987b57d6e 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -61,7 +61,7 @@ namespace osu.Game.Overlays Header.HeaderContent.Picker.Beatmap.ValueChanged += b => { - info.Beatmap = b.NewValue; + info.BeatmapInfo = b.NewValue; ScrollFlow.ScrollToStart(); }; } 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/ChangelogHeader.cs b/osu.Game/Overlays/Changelog/ChangelogHeader.cs index f4be4328e7..52dea63ab7 100644 --- a/osu.Game/Overlays/Changelog/ChangelogHeader.cs +++ b/osu.Game/Overlays/Changelog/ChangelogHeader.cs @@ -9,7 +9,10 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Changelog { @@ -21,16 +24,16 @@ namespace osu.Game.Overlays.Changelog public ChangelogUpdateStreamControl Streams; - private const string listing_string = "listing"; + public static LocalisableString ListingString => LayoutStrings.HeaderChangelogIndex; private Box streamsBackground; public ChangelogHeader() { - TabControl.AddItem(listing_string); + TabControl.AddItem(ListingString); Current.ValueChanged += e => { - if (e.NewValue == listing_string) + if (e.NewValue == ListingString) ListingSelected?.Invoke(); }; @@ -63,7 +66,7 @@ namespace osu.Game.Overlays.Changelog } else { - Current.Value = listing_string; + Current.Value = ListingString; Streams.Current.Value = null; } } @@ -114,8 +117,8 @@ namespace osu.Game.Overlays.Changelog { public ChangelogHeaderTitle() { - Title = "changelog"; - Description = "track recent dev updates in the osu! ecosystem"; + Title = PageTitleStrings.MainChangelogControllerDefault; + Description = NamedOverlayComponentStrings.ChangelogDescription; IconTexture = "Icons/Hexacons/devtools"; } } diff --git a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs index 8b89d63aab..93486274fc 100644 --- a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs @@ -71,6 +71,17 @@ namespace osu.Game.Overlays.Changelog Colour = colourProvider.Background6, Margin = new MarginPadding { Top = 30 }, }, + new ChangelogSupporterPromo + { + Alpha = api.LocalUser.Value.IsSupporter ? 0 : 1, + }, + new Box + { + RelativeSizeAxes = Axes.X, + Height = 2, + Colour = colourProvider.Background6, + Alpha = api.LocalUser.Value.IsSupporter ? 0 : 1, + }, comments = new CommentsContainer() }; diff --git a/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs b/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs new file mode 100644 index 0000000000..1b0a62dc4a --- /dev/null +++ b/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs @@ -0,0 +1,187 @@ +// Copyright (c) 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Changelog +{ + public class ChangelogSupporterPromo : CompositeDrawable + { + private const float image_container_width = 164; + private const float heart_size = 75; + + public ChangelogSupporterPromo() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding + { + Vertical = 20, + Horizontal = 50, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colour, TextureStore textures, OverlayColourProvider colourProvider) + { + SupporterPromoLinkFlowContainer supportLinkText; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 6, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Offset = new Vector2(0, 1), + Radius = 3, + }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 200, + Padding = new MarginPadding { Horizontal = 75 }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Right = 50 + image_container_width }, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = ChangelogStrings.SupportHeading, + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Light), + Margin = new MarginPadding { Bottom = 20 }, + }, + supportLinkText = new SupporterPromoLinkFlowContainer(t => + { + t.Font = t.Font.With(size: 14); + t.Colour = colour.PinkLighter; + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + new OsuTextFlowContainer(t => + { + t.Font = t.Font.With(size: 12); + t.Colour = colour.PinkLighter; + }) + { + Text = ChangelogStrings.SupportText2.ToString(), + Margin = new MarginPadding { Top = 10 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + }, + }, + new Container + { + RelativeSizeAxes = Axes.Y, + Width = image_container_width, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Children = new Drawable[] + { + new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Bottom = 28 }, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Texture = textures.Get(@"Online/supporter-pippi"), + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(heart_size), + Margin = new MarginPadding { Top = 70 }, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = colour.Pink, + Radius = 10, + Roundness = heart_size / 2, + }, + Child = new Sprite + { + Size = new Vector2(heart_size), + Texture = textures.Get(@"Online/supporter-heart"), + }, + }, + } + } + } + }, + } + }, + }; + + supportLinkText.AddText("Support further development of osu! and "); + supportLinkText.AddLink("become an osu!supporter", @"https://osu.ppy.sh/home/support", t => t.Font = t.Font.With(weight: FontWeight.Bold)); + supportLinkText.AddText(" today!"); + } + + private class SupporterPromoLinkFlowContainer : LinkFlowContainer + { + public SupporterPromoLinkFlowContainer(Action defaultCreationParameters) + : base(defaultCreationParameters) + { + } + + protected override DrawableLinkCompiler CreateLinkCompiler(IEnumerable parts) => new SupporterPromoLinkCompiler(parts); + + private class SupporterPromoLinkCompiler : DrawableLinkCompiler + { + public SupporterPromoLinkCompiler(IEnumerable parts) + : base(parts) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + IdleColour = colour.PinkDark; + HoverColour = Color4.White; + } + } + } + } +} diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index a8f2e654d7..ce12e9554d 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -10,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -91,9 +92,9 @@ namespace osu.Game.Overlays Show(); } - public override bool OnPressed(GlobalAction action) + public override bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Back: if (Current.Value == null) diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 01cfe9a55b..6f4f568eb2 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -110,15 +110,15 @@ namespace osu.Game.Overlays.Chat EdgeEffect = new EdgeEffectParameters { Roundness = 1, - Offset = new Vector2(0, 3), - Radius = 3, + Radius = 1, Colour = Color4.Black.Opacity(0.3f), + Offset = new Vector2(0, 1), Type = EdgeEffectType.Shadow, }, Child = new Container { AutoSizeAxes = Axes.Both, - Y = 3, + Y = 0, Masking = true, CornerRadius = 4, Children = new Drawable[] diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs index cca4dc33e5..9d2cd8a21d 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs @@ -3,6 +3,9 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,6 +16,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; using osuTK; using osuTK.Graphics; @@ -39,6 +43,8 @@ namespace osu.Game.Overlays.Chat.Tabs protected override Container Content => content; + private Sample sampleTabSwitched; + public ChannelTabItem(Channel value) : base(value) { @@ -112,6 +118,7 @@ namespace osu.Game.Overlays.Chat.Tabs }, }, }, + new HoverSounds() }; } @@ -146,17 +153,18 @@ namespace osu.Game.Overlays.Chat.Tabs switch (e.Button) { case MouseButton.Middle: - CloseButton.Click(); + CloseButton.TriggerClick(); break; } } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, AudioManager audio) { BackgroundActive = colours.ChatBlue; BackgroundInactive = colours.Gray4; backgroundHover = colours.Gray7; + sampleTabSwitched = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); highlightBox.Colour = colours.Yellow; } @@ -217,7 +225,14 @@ namespace osu.Game.Overlays.Chat.Tabs Text.Font = Text.Font.With(weight: FontWeight.Medium); } - protected override void OnActivated() => updateState(); + protected override void OnActivated() + { + if (IsLoaded) + sampleTabSwitched?.Play(); + + updateState(); + } + protected override void OnDeactivated() => updateState(); } } diff --git a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs index 7c82420e08..d01aec630e 100644 --- a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Chat.Tabs if (value.Type != ChannelType.PM) throw new ArgumentException("Argument value needs to have the targettype user!"); - ClickableAvatar avatar; + DrawableAvatar avatar; AddRange(new Drawable[] { @@ -48,10 +48,9 @@ namespace osu.Game.Overlays.Chat.Tabs Anchor = Anchor.Centre, Origin = Anchor.Centre, Masking = true, - Child = new DelayedLoadWrapper(avatar = new ClickableAvatar(value.Users.First()) + Child = new DelayedLoadWrapper(avatar = new DrawableAvatar(value.Users.First()) { - RelativeSizeAxes = Axes.Both, - OpenOnClick = false, + RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 0aa6108815..4b27335c7c 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -284,6 +284,10 @@ namespace osu.Game.Overlays if (currentChannel.Value != e.NewValue) return; + // check once more to ensure the channel hasn't since been removed from the loaded channels list (may have been left by some automated means). + if (!loadedChannels.Contains(loaded)) + return; + loading.Hide(); currentChannelContainer.Clear(false); @@ -372,19 +376,19 @@ namespace osu.Game.Overlays return base.OnKeyDown(e); } - public bool OnPressed(PlatformAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action.ActionType) + switch (e.Action) { - case PlatformActionType.TabNew: + case PlatformAction.TabNew: ChannelTabControl.SelectChannelSelectorTab(); return true; - case PlatformActionType.TabRestore: + case PlatformAction.TabRestore: channelManager.JoinLastClosedChannel(); return true; - case PlatformActionType.DocumentClose: + case PlatformAction.DocumentClose: channelManager.LeaveChannel(channelManager.CurrentChannel.Value); return true; } @@ -392,7 +396,7 @@ namespace osu.Game.Overlays return false; } - public void OnReleased(PlatformAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -444,10 +448,9 @@ namespace osu.Game.Overlays if (loaded != null) { - loadedChannels.Remove(loaded); - // Because the container is only cleared in the async load callback of a new channel, it is forcefully cleared // to ensure that the previous channel doesn't get updated after it's disposed + loadedChannels.Remove(loaded); currentChannelContainer.Remove(loaded); loaded.Dispose(); } diff --git a/osu.Game/Overlays/Comments/CommentEditor.cs b/osu.Game/Overlays/Comments/CommentEditor.cs index 7b4bf882dc..20a8ab64f7 100644 --- a/osu.Game/Overlays/Comments/CommentEditor.cs +++ b/osu.Game/Overlays/Comments/CommentEditor.cs @@ -120,7 +120,7 @@ namespace osu.Game.Overlays.Comments if (commitButton.IsBlocked.Value) return; - commitButton.Click(); + commitButton.TriggerClick(); }; } diff --git a/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs new file mode 100644 index 0000000000..aeab292b0d --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentMarkdownContainer.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 Markdig.Syntax; +using Markdig.Syntax.Inlines; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Graphics.Containers.Markdown; + +namespace osu.Game.Overlays.Comments +{ + public class CommentMarkdownContainer : OsuMarkdownContainer + { + public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer(); + + protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock); + + private class CommentMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer + { + // Don't render image in comment for now + protected override void AddImage(LinkInline linkInline) { } + } + + private class CommentMarkdownHeading : OsuMarkdownHeading + { + public CommentMarkdownHeading(HeadingBlock headingBlock) + : base(headingBlock) + { + } + + protected override float GetFontSizeByLevel(int level) + { + var defaultFontSize = base.GetFontSizeByLevel(6); + + switch (level) + { + case 1: + return 1.2f * defaultFontSize; + + case 2: + return 1.1f * defaultFontSize; + + default: + return defaultFontSize; + } + } + } + } +} diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index 513fabf52a..6b3d816e84 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -14,6 +14,9 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Threading; using osu.Game.Users; +using System.Collections.Generic; +using JetBrains.Annotations; +using osu.Game.Graphics.Sprites; namespace osu.Game.Overlays.Comments { @@ -35,6 +38,7 @@ namespace osu.Game.Overlays.Comments private CancellationTokenSource loadCancellation; private int currentPage; + private FillFlowContainer pinnedContent; private FillFlowContainer content; private DeletedCommentsCounter deletedCommentsCounter; private CommentsShowMoreButton moreButton; @@ -60,6 +64,25 @@ namespace osu.Game.Overlays.Comments Children = new Drawable[] { commentCounter = new TotalCommentsCounter(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + pinnedContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + }, + }, new CommentsHeader { Sort = { BindTarget = Sort }, @@ -147,7 +170,7 @@ namespace osu.Game.Overlays.Comments private void refetchComments() { - clearComments(); + ClearComments(); getComments(); } @@ -160,50 +183,128 @@ namespace osu.Game.Overlays.Comments loadCancellation?.Cancel(); scheduledCommentsLoad?.Cancel(); request = new GetCommentsRequest(id.Value, type.Value, Sort.Value, currentPage++, 0); - request.Success += res => scheduledCommentsLoad = Schedule(() => onSuccess(res)); + request.Success += res => scheduledCommentsLoad = Schedule(() => OnSuccess(res)); api.PerformAsync(request); } - private void clearComments() + protected void ClearComments() { currentPage = 1; deletedCommentsCounter.Count.Value = 0; moreButton.Show(); moreButton.IsLoading = true; + pinnedContent.Clear(); content.Clear(); + CommentDictionary.Clear(); } - private void onSuccess(CommentBundle response) + protected readonly Dictionary CommentDictionary = new Dictionary(); + + protected void OnSuccess(CommentBundle response) { - loadCancellation = new CancellationTokenSource(); + commentCounter.Current.Value = response.Total; - LoadComponentAsync(new CommentsPage(response) + if (!response.Comments.Any()) { - ShowDeleted = { BindTarget = ShowDeleted }, - Sort = { BindTarget = Sort }, - Type = { BindTarget = type }, - CommentableId = { BindTarget = id } - }, loaded => + content.Add(new NoCommentsPlaceholder()); + moreButton.Hide(); + return; + } + + AppendComments(response); + } + + /// + /// Appends retrieved comments to the subtree rooted of comments in this page. + /// + /// The bundle of comments to add. + protected void AppendComments([NotNull] CommentBundle bundle) + { + var topLevelComments = new List(); + var orphaned = new List(); + + foreach (var comment in bundle.Comments.Concat(bundle.IncludedComments).Concat(bundle.PinnedComments)) { - content.Add(loaded); + // Exclude possible duplicated comments. + if (CommentDictionary.ContainsKey(comment.Id)) + continue; - deletedCommentsCounter.Count.Value += response.Comments.Count(c => c.IsDeleted && c.IsTopLevel); + addNewComment(comment); + } - if (response.HasMore) + // Comments whose parents were seen later than themselves can now be added. + foreach (var o in orphaned) + addNewComment(o); + + if (topLevelComments.Any()) + { + LoadComponentsAsync(topLevelComments, loaded => { - int loadedTopLevelComments = 0; - content.Children.OfType().ForEach(p => loadedTopLevelComments += p.Children.OfType().Count()); + pinnedContent.AddRange(loaded.Where(d => d.Comment.Pinned)); + content.AddRange(loaded.Where(d => !d.Comment.Pinned)); - moreButton.Current.Value = response.TopLevelCount - loadedTopLevelComments; - moreButton.IsLoading = false; + deletedCommentsCounter.Count.Value += topLevelComments.Select(d => d.Comment).Count(c => c.IsDeleted && c.IsTopLevel); + + if (bundle.HasMore) + { + int loadedTopLevelComments = 0; + pinnedContent.Children.OfType().ForEach(p => loadedTopLevelComments++); + content.Children.OfType().ForEach(p => loadedTopLevelComments++); + + moreButton.Current.Value = bundle.TopLevelCount - loadedTopLevelComments; + moreButton.IsLoading = false; + } + else + { + moreButton.Hide(); + } + }, (loadCancellation = new CancellationTokenSource()).Token); + } + + void addNewComment(Comment comment) + { + var drawableComment = getDrawableComment(comment); + + if (comment.ParentId == null) + { + // Comments that have no parent are added as top-level comments to the flow. + topLevelComments.Add(drawableComment); + } + else if (CommentDictionary.TryGetValue(comment.ParentId.Value, out var parentDrawable)) + { + // The comment's parent has already been seen, so the parent<-> child links can be added. + comment.ParentComment = parentDrawable.Comment; + parentDrawable.Replies.Add(drawableComment); } else { - moreButton.Hide(); + // The comment's parent has not been seen yet, so keep it orphaned for the time being. This can occur if the comments arrive out of order. + // Since this comment has now been seen, any further children can be added to it without being orphaned themselves. + orphaned.Add(comment); } + } + } - commentCounter.Current.Value = response.Total; - }, loadCancellation.Token); + private DrawableComment getDrawableComment(Comment comment) + { + if (CommentDictionary.TryGetValue(comment.Id, out var existing)) + return existing; + + return CommentDictionary[comment.Id] = new DrawableComment(comment) + { + ShowDeleted = { BindTarget = ShowDeleted }, + Sort = { BindTarget = Sort }, + RepliesRequested = onCommentRepliesRequested + }; + } + + private void onCommentRepliesRequested(DrawableComment drawableComment, int page) + { + var req = new GetCommentsRequest(id.Value, type.Value, Sort.Value, page, drawableComment.Comment.Id); + + req.Success += response => Schedule(() => AppendComments(response)); + + api.PerformAsync(req); } protected override void Dispose(bool isDisposing) @@ -212,5 +313,25 @@ namespace osu.Game.Overlays.Comments loadCancellation?.Cancel(); base.Dispose(isDisposing); } + + private class NoCommentsPlaceholder : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Height = 80; + RelativeSizeAxes = Axes.X; + AddRangeInternal(new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 50 }, + Text = @"No comments yet." + } + }); + } + } } } diff --git a/osu.Game/Overlays/Comments/CommentsPage.cs b/osu.Game/Overlays/Comments/CommentsPage.cs deleted file mode 100644 index 9b146b0a7d..0000000000 --- a/osu.Game/Overlays/Comments/CommentsPage.cs +++ /dev/null @@ -1,161 +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.Containers; -using osu.Framework.Graphics; -using osu.Framework.Bindables; -using osu.Game.Online.API.Requests.Responses; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.Sprites; -using System.Linq; -using osu.Game.Online.API.Requests; -using osu.Game.Online.API; -using System.Collections.Generic; -using JetBrains.Annotations; - -namespace osu.Game.Overlays.Comments -{ - public class CommentsPage : CompositeDrawable - { - public readonly BindableBool ShowDeleted = new BindableBool(); - public readonly Bindable Sort = new Bindable(); - public readonly Bindable Type = new Bindable(); - public readonly BindableLong CommentableId = new BindableLong(); - - [Resolved] - private IAPIProvider api { get; set; } - - private readonly CommentBundle commentBundle; - private FillFlowContainer flow; - - public CommentsPage(CommentBundle commentBundle) - { - this.commentBundle = commentBundle; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - AddRangeInternal(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5 - }, - flow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - } - }); - - if (!commentBundle.Comments.Any()) - { - flow.Add(new NoCommentsPlaceholder()); - return; - } - - AppendComments(commentBundle); - } - - private DrawableComment getDrawableComment(Comment comment) - { - if (CommentDictionary.TryGetValue(comment.Id, out var existing)) - return existing; - - return CommentDictionary[comment.Id] = new DrawableComment(comment) - { - ShowDeleted = { BindTarget = ShowDeleted }, - Sort = { BindTarget = Sort }, - RepliesRequested = onCommentRepliesRequested - }; - } - - private void onCommentRepliesRequested(DrawableComment drawableComment, int page) - { - var request = new GetCommentsRequest(CommentableId.Value, Type.Value, Sort.Value, page, drawableComment.Comment.Id); - - request.Success += response => Schedule(() => AppendComments(response)); - - api.PerformAsync(request); - } - - protected readonly Dictionary CommentDictionary = new Dictionary(); - - /// - /// Appends retrieved comments to the subtree rooted of comments in this page. - /// - /// The bundle of comments to add. - protected void AppendComments([NotNull] CommentBundle bundle) - { - var orphaned = new List(); - - foreach (var comment in bundle.Comments.Concat(bundle.IncludedComments)) - { - // Exclude possible duplicated comments. - if (CommentDictionary.ContainsKey(comment.Id)) - continue; - - addNewComment(comment); - } - - // Comments whose parents were seen later than themselves can now be added. - foreach (var o in orphaned) - addNewComment(o); - - void addNewComment(Comment comment) - { - var drawableComment = getDrawableComment(comment); - - if (comment.ParentId == null) - { - // Comments that have no parent are added as top-level comments to the flow. - flow.Add(drawableComment); - } - else if (CommentDictionary.TryGetValue(comment.ParentId.Value, out var parentDrawable)) - { - // The comment's parent has already been seen, so the parent<-> child links can be added. - comment.ParentComment = parentDrawable.Comment; - parentDrawable.Replies.Add(drawableComment); - } - else - { - // The comment's parent has not been seen yet, so keep it orphaned for the time being. This can occur if the comments arrive out of order. - // Since this comment has now been seen, any further children can be added to it without being orphaned themselves. - orphaned.Add(comment); - } - } - } - - private class NoCommentsPlaceholder : CompositeDrawable - { - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - Height = 80; - RelativeSizeAxes = Axes.X; - AddRangeInternal(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4 - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Left = 50 }, - Text = @"No comments yet." - } - }); - } - } - } -} diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index d94f8c4b8b..a44f3a7643 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Bindables; using System.Linq; using osu.Game.Graphics.Sprites; -using osu.Game.Online.Chat; using osu.Framework.Allocation; using System.Collections.Generic; using System; @@ -22,6 +21,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using System.Collections.Specialized; using osu.Framework.Localisation; using osu.Game.Overlays.Comments.Buttons; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments { @@ -61,7 +61,7 @@ namespace osu.Game.Overlays.Comments { LinkFlowContainer username; FillFlowContainer info; - LinkFlowContainer message; + CommentMarkdownContainer message; GridContainer content; VotePill votePill; @@ -138,12 +138,13 @@ namespace osu.Game.Overlays.Comments AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(10, 0), - Children = new Drawable[] + Children = new[] { username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold)) { AutoSizeAxes = Axes.Both }, + Comment.Pinned ? new PinnedCommentNotice() : Empty(), new ParentUsername(Comment), new OsuSpriteText { @@ -153,10 +154,12 @@ namespace osu.Game.Overlays.Comments } } }, - message = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14)) + message = new CommentMarkdownContainer { RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y + AutoSizeAxes = Axes.Y, + DocumentMargin = new MarginPadding(0), + DocumentPadding = new MarginPadding(0), }, info = new FillFlowContainer { @@ -275,10 +278,7 @@ namespace osu.Game.Overlays.Comments } if (Comment.HasMessage) - { - var formattedSource = MessageFormatter.FormatText(Comment.Message); - message.AddLinks(formattedSource.Text, formattedSource.Links); - } + message.Text = Comment.Message; if (Comment.IsDeleted) { @@ -323,9 +323,7 @@ namespace osu.Game.Overlays.Comments this.FadeTo(show.NewValue ? 1 : 0); }, true); childrenExpanded.BindValueChanged(expanded => childCommentsVisibilityContainer.FadeTo(expanded.NewValue ? 1 : 0), true); - updateButtonsState(); - base.LoadComplete(); } @@ -394,6 +392,33 @@ namespace osu.Game.Overlays.Comments }; } + private class PinnedCommentNotice : FillFlowContainer + { + public PinnedCommentNotice() + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(2, 0); + Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.Thumbtack, + Size = new Vector2(14), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + Text = CommentsStrings.Pinned, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + }; + } + } + private class ParentUsername : FillFlowContainer, IHasTooltip { public LocalisableString TooltipText => getParentMessage(); diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs index 056d4ad6f7..2c8db14950 100644 --- a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs @@ -1,9 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.ComponentModel; using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard @@ -16,37 +16,19 @@ namespace osu.Game.Overlays.Dashboard { public DashboardTitle() { - Title = HomeStrings.UserTitle; - Description = "view your friends and other information"; + Title = PageTitleStrings.MainHomeControllerIndex; + Description = NamedOverlayComponentStrings.DashboardDescription; IconTexture = "Icons/Hexacons/social"; } } } - [LocalisableEnum(typeof(DashboardOverlayTabsEnumLocalisationMapper))] public enum DashboardOverlayTabs { + [LocalisableDescription(typeof(FriendsStrings), nameof(FriendsStrings.TitleCompact))] Friends, [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/OnlineStatus.cs b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs index 4b5a7ef066..853c94d8ae 100644 --- a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs +++ b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs @@ -1,38 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard.Friends { - [LocalisableEnum(typeof(OnlineStatusEnumLocalisationMapper))] public enum OnlineStatus { + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.StatusAll))] All, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.StatusOnline))] Online, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.StatusOffline))] 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 dc756e2957..7fee5f4668 100644 --- a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs +++ b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.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.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -12,33 +11,16 @@ namespace osu.Game.Overlays.Dashboard.Friends { } - [LocalisableEnum(typeof(UserSortCriteriaEnumLocalisationMappper))] public enum UserSortCriteria { + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.LastVisit))] [Description(@"Recently Active")] LastVisit, + + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.Rank))] Rank, + + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.Username))] 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/Dashboard/Home/News/FeaturedNewsItemPanel.cs b/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs index ee88469e2f..0d166eb858 100644 --- a/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs @@ -140,12 +140,8 @@ namespace osu.Game.Overlays.Dashboard.Home.News } } - private class Date : CompositeDrawable, IHasCustomTooltip + private class Date : CompositeDrawable, IHasCustomTooltip { - public ITooltip GetCustomTooltip() => new DateTooltip(); - - public object TooltipContent => date; - private readonly DateTimeOffset date; public Date(DateTimeOffset date) @@ -190,6 +186,10 @@ namespace osu.Game.Overlays.Dashboard.Home.News } }; } + + ITooltip IHasCustomTooltip.GetCustomTooltip() => new DateTooltip(); + + DateTimeOffset IHasCustomTooltip.TooltipContent => date; } } } diff --git a/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs b/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs index dc4f3f8c92..77cfbc90b0 100644 --- a/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs +++ b/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs @@ -67,12 +67,8 @@ namespace osu.Game.Overlays.Dashboard.Home.News }; } - private class Date : CompositeDrawable, IHasCustomTooltip + private class Date : CompositeDrawable, IHasCustomTooltip { - public ITooltip GetCustomTooltip() => new DateTooltip(); - - public object TooltipContent => date; - private readonly DateTimeOffset date; public Date(DateTimeOffset date) @@ -110,6 +106,10 @@ namespace osu.Game.Overlays.Dashboard.Home.News t.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Regular); }); } + + ITooltip IHasCustomTooltip.GetCustomTooltip() => new DateTooltip(); + + DateTimeOffset IHasCustomTooltip.TooltipContent => date; } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index cd02900e88..78ef2ec795 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -218,7 +218,7 @@ namespace osu.Game.Overlays.Dialog /// /// Programmatically clicks the first . /// - public void PerformOkAction() => Buttons.OfType().First().Click(); + public void PerformOkAction() => Buttons.OfType().First().TriggerClick(); protected override bool OnKeyDown(KeyDownEvent e) { @@ -265,7 +265,7 @@ namespace osu.Game.Overlays.Dialog if (!actionInvoked && content.IsPresent) // In the case a user did not choose an action before a hide was triggered, press the last button. // This is presumed to always be a sane default "cancel" action. - buttonsContainer.Last().Click(); + buttonsContainer.Last().TriggerClick(); content.FadeOut(EXIT_DURATION, Easing.InSine); } @@ -273,7 +273,7 @@ namespace osu.Game.Overlays.Dialog private void pressButtonAtIndex(int index) { if (index < Buttons.Count()) - Buttons.Skip(index).First().Click(); + Buttons.Skip(index).First().TriggerClick(); } } } diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index bc3b0e6c9a..9db0f34d1b 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -7,6 +7,10 @@ using osu.Game.Overlays.Dialog; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Input.Events; +using osu.Game.Audio.Effects; namespace osu.Game.Overlays { @@ -17,6 +21,8 @@ namespace osu.Game.Overlays protected override string PopInSampleName => "UI/dialog-pop-in"; protected override string PopOutSampleName => "UI/dialog-pop-out"; + private AudioFilter lowPassFilter; + public PopupDialog CurrentDialog { get; private set; } public DialogOverlay() @@ -33,6 +39,12 @@ namespace osu.Game.Overlays Origin = Anchor.BottomCentre; } + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + AddInternal(lowPassFilter = new AudioFilter(audio.TrackMixer)); + } + public void Push(PopupDialog dialog) { if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return; @@ -48,6 +60,8 @@ namespace osu.Game.Overlays Show(); } + public override bool IsPresent => dialogContainer.Children.Count > 0; + protected override bool BlockNonPositionalInput => true; private void onDialogOnStateChanged(VisibilityContainer dialog, Visibility v) @@ -68,12 +82,15 @@ namespace osu.Game.Overlays { base.PopIn(); this.FadeIn(PopupDialog.ENTER_DURATION, Easing.OutQuint); + lowPassFilter.CutoffTo(300, 100, Easing.OutCubic); } protected override void PopOut() { base.PopOut(); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); + if (CurrentDialog?.State.Value == Visibility.Visible) { CurrentDialog.Hide(); @@ -83,16 +100,16 @@ namespace osu.Game.Overlays this.FadeOut(PopupDialog.EXIT_DURATION, Easing.InSine); } - public override bool OnPressed(GlobalAction action) + public override bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Select: - CurrentDialog?.Buttons.OfType().FirstOrDefault()?.Click(); + CurrentDialog?.Buttons.OfType().FirstOrDefault()?.TriggerClick(); return true; } - return base.OnPressed(action); + return base.OnPressed(e); } } } diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs new file mode 100644 index 0000000000..f7842dcd30 --- /dev/null +++ b/osu.Game/Overlays/Login/LoginForm.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays.Settings; +using osuTK; + +namespace osu.Game.Overlays.Login +{ + public class LoginForm : FillFlowContainer + { + private TextBox username; + private TextBox password; + private ShakeContainer shakeSignIn; + + [Resolved(CanBeNull = true)] + private IAPIProvider api { get; set; } + + public Action RequestHide; + + private void performLogin() + { + if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) + api?.Login(username.Text, password.Text); + else + shakeSignIn.Shake(); + } + + [BackgroundDependencyLoader(permitNulls: true)] + private void load(OsuConfigManager config, AccountCreationOverlay accountCreation) + { + Direction = FillDirection.Vertical; + Spacing = new Vector2(0, 5); + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + ErrorTextFlowContainer errorText; + + Children = new Drawable[] + { + username = new OsuTextBox + { + PlaceholderText = "username", + RelativeSizeAxes = Axes.X, + Text = api?.ProvidedUsername ?? string.Empty, + TabbableContentContainer = this + }, + password = new OsuPasswordTextBox + { + PlaceholderText = "password", + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + }, + errorText = new ErrorTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + new SettingsCheckbox + { + LabelText = "Remember username", + Current = config.GetBindable(OsuSetting.SaveUsername), + }, + new SettingsCheckbox + { + LabelText = "Stay signed in", + Current = config.GetBindable(OsuSetting.SavePassword), + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + shakeSignIn = new ShakeContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new SettingsButton + { + Text = "Sign in", + Action = performLogin + }, + } + } + }, + new SettingsButton + { + Text = "Register", + Action = () => + { + RequestHide(); + accountCreation.Show(); + } + } + }; + + password.OnCommit += (sender, newText) => performLogin(); + + if (api?.LastLoginError?.Message is string error) + errorText.AddErrors(new[] { error }); + } + + public override bool AcceptsFocus => true; + + protected override bool OnClick(ClickEvent e) => true; + + protected override void OnFocus(FocusEvent e) + { + Schedule(() => { GetContainingInputManager().ChangeFocus(string.IsNullOrEmpty(username.Text) ? username : password); }); + } + } +} diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs new file mode 100644 index 0000000000..d1e5bfe809 --- /dev/null +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -0,0 +1,199 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +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.Online.API; +using osu.Game.Users; +using osuTK; +using RectangleF = osu.Framework.Graphics.Primitives.RectangleF; +using Container = osu.Framework.Graphics.Containers.Container; + +namespace osu.Game.Overlays.Login +{ + public class LoginPanel : FillFlowContainer + { + private bool bounding = true; + private LoginForm form; + + [Resolved] + private OsuColour colours { get; set; } + + private UserGridPanel panel; + private UserDropdown dropdown; + + /// + /// Called to request a hide of a parent displaying this container. + /// + public Action RequestHide; + + private readonly IBindable apiState = new Bindable(); + + [Resolved] + private IAPIProvider api { get; set; } + + public override RectangleF BoundingBox => bounding ? base.BoundingBox : RectangleF.Empty; + + public bool Bounding + { + get => bounding; + set + { + bounding = value; + Invalidate(Invalidation.MiscGeometry); + } + } + + public LoginPanel() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Vertical; + Spacing = new Vector2(0f, 5f); + } + + [BackgroundDependencyLoader] + private void load() + { + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); + } + + private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => + { + form = null; + + switch (state.NewValue) + { + case APIState.Offline: + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "ACCOUNT", + Margin = new MarginPadding { Bottom = 5 }, + Font = OsuFont.GetFont(weight: FontWeight.Bold), + }, + form = new LoginForm + { + RequestHide = RequestHide + } + }; + break; + + case APIState.Failing: + case APIState.Connecting: + LinkFlowContainer linkFlow; + + Children = new Drawable[] + { + new LoadingSpinner + { + State = { Value = Visibility.Visible }, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + linkFlow = new LinkFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + TextAnchor = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Text = state.NewValue == APIState.Failing ? "Connection is failing, will attempt to reconnect... " : "Attempting to connect... ", + Margin = new MarginPadding { Top = 10, Bottom = 10 }, + }, + }; + + linkFlow.AddLink("cancel", api.Logout, string.Empty); + break; + + case APIState.Online: + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = 20, Right = 20 }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Signed in", + Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold), + Margin = new MarginPadding { Top = 5, Bottom = 5 }, + }, + }, + }, + panel = new UserGridPanel(api.LocalUser.Value) + { + RelativeSizeAxes = Axes.X, + Action = RequestHide + }, + dropdown = new UserDropdown { RelativeSizeAxes = Axes.X }, + }, + }, + }; + + panel.Status.BindTo(api.LocalUser.Value.Status); + panel.Activity.BindTo(api.LocalUser.Value.Activity); + + dropdown.Current.BindValueChanged(action => + { + switch (action.NewValue) + { + case UserAction.Online: + api.LocalUser.Value.Status.Value = new UserStatusOnline(); + dropdown.StatusColour = colours.Green; + break; + + case UserAction.DoNotDisturb: + api.LocalUser.Value.Status.Value = new UserStatusDoNotDisturb(); + dropdown.StatusColour = colours.Red; + break; + + case UserAction.AppearOffline: + api.LocalUser.Value.Status.Value = new UserStatusOffline(); + dropdown.StatusColour = colours.Gray7; + break; + + case UserAction.SignOut: + api.Logout(); + break; + } + }, true); + break; + } + + if (form != null) GetContainingInputManager()?.ChangeFocus(form); + }); + + public override bool AcceptsFocus => true; + + protected override bool OnClick(ClickEvent e) => true; + + protected override void OnFocus(FocusEvent e) + { + if (form != null) GetContainingInputManager().ChangeFocus(form); + base.OnFocus(e); + } + } +} diff --git a/osu.Game/Overlays/Login/UserAction.cs b/osu.Game/Overlays/Login/UserAction.cs new file mode 100644 index 0000000000..07b6b4bf7e --- /dev/null +++ b/osu.Game/Overlays/Login/UserAction.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.ComponentModel; + +namespace osu.Game.Overlays.Login +{ + public enum UserAction + { + Online, + + [Description(@"Do not disturb")] + DoNotDisturb, + + [Description(@"Appear offline")] + AppearOffline, + + [Description(@"Sign out")] + SignOut, + } +} \ No newline at end of file diff --git a/osu.Game/Overlays/Login/UserDropdown.cs b/osu.Game/Overlays/Login/UserDropdown.cs new file mode 100644 index 0000000000..ac4e7f8eda --- /dev/null +++ b/osu.Game/Overlays/Login/UserDropdown.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Login +{ + public class UserDropdown : OsuEnumDropdown + { + protected override DropdownHeader CreateHeader() => new UserDropdownHeader(); + + protected override DropdownMenu CreateMenu() => new UserDropdownMenu(); + + public Color4 StatusColour + { + set + { + if (Header is UserDropdownHeader h) + h.StatusColour = value; + } + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.Gray5; + } + + protected class UserDropdownMenu : OsuDropdownMenu + { + public UserDropdownMenu() + { + Masking = true; + CornerRadius = 5; + + Margin = new MarginPadding { Bottom = 5 }; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 4, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.Gray3; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableUserDropdownMenuItem(item); + + private class DrawableUserDropdownMenuItem : DrawableOsuDropdownMenuItem + { + public DrawableUserDropdownMenuItem(MenuItem item) + : base(item) + { + Foreground.Padding = new MarginPadding { Top = 5, Bottom = 5, Left = 10, Right = 5 }; + CornerRadius = 5; + } + + protected override Drawable CreateContent() => new Content + { + Label = { Margin = new MarginPadding { Left = UserDropdownHeader.LABEL_LEFT_MARGIN - 11 } } + }; + } + } + + private class UserDropdownHeader : OsuDropdownHeader + { + public const float LABEL_LEFT_MARGIN = 20; + + private readonly SpriteIcon statusIcon; + + public Color4 StatusColour + { + set => statusIcon.FadeColour(value, 500, Easing.OutQuint); + } + + public UserDropdownHeader() + { + Foreground.Padding = new MarginPadding { Left = 10, Right = 10 }; + Margin = new MarginPadding { Bottom = 5 }; + Masking = true; + CornerRadius = 5; + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 4, + }; + + Icon.Size = new Vector2(14); + Icon.Margin = new MarginPadding(0); + + Foreground.Add(statusIcon = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Regular.Circle, + Size = new Vector2(14), + }); + + Text.Margin = new MarginPadding { Left = LABEL_LEFT_MARGIN }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.Gray3; + } + } + } +} diff --git a/osu.Game/Overlays/LoginOverlay.cs b/osu.Game/Overlays/LoginOverlay.cs index d0411ba9e7..f3562aa6d9 100644 --- a/osu.Game/Overlays/LoginOverlay.cs +++ b/osu.Game/Overlays/LoginOverlay.cs @@ -5,26 +5,20 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; -using osu.Game.Overlays.Settings.Sections.General; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; -using System; +using osu.Game.Overlays.Login; namespace osu.Game.Overlays { public class LoginOverlay : OsuFocusedOverlayContainer { - private LoginSettings settingsSection; + private LoginPanel panel; private const float transition_time = 400; - /// - /// Provide a source for the toolbar height. - /// - public Func GetToolbarHeight; - public LoginOverlay() { AutoSizeAxes = Axes.Both; @@ -56,7 +50,7 @@ namespace osu.Game.Overlays AutoSizeEasing = Easing.OutQuint, Children = new Drawable[] { - settingsSection = new LoginSettings + panel = new LoginPanel { Padding = new MarginPadding(10), RequestHide = Hide, @@ -81,25 +75,18 @@ namespace osu.Game.Overlays { base.PopIn(); - settingsSection.Bounding = true; + panel.Bounding = true; this.FadeIn(transition_time, Easing.OutQuint); - GetContainingInputManager().ChangeFocus(settingsSection); + GetContainingInputManager().ChangeFocus(panel); } protected override void PopOut() { base.PopOut(); - settingsSection.Bounding = false; + panel.Bounding = false; this.FadeOut(transition_time); } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; - } } } 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/IncompatibilityDisplayingModButton.cs b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModButton.cs new file mode 100644 index 0000000000..0f51439252 --- /dev/null +++ b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModButton.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public class IncompatibilityDisplayingModButton : ModButton + { + private readonly CompositeDrawable incompatibleIcon; + + [Resolved] + private Bindable> selectedMods { get; set; } + + public IncompatibilityDisplayingModButton(Mod mod) + : base(mod) + { + ButtonContent.Add(incompatibleIcon = new IncompatibleIcon + { + Anchor = Anchor.BottomRight, + Origin = Anchor.Centre, + Position = new Vector2(-13), + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedMods.BindValueChanged(_ => Scheduler.AddOnce(updateCompatibility), true); + } + + protected override void DisplayMod(Mod mod) + { + base.DisplayMod(mod); + + Scheduler.AddOnce(updateCompatibility); + } + + private void updateCompatibility() + { + var m = SelectedMod ?? Mods.First(); + + bool isIncompatible = false; + + if (selectedMods.Value.Count > 0 && !selectedMods.Value.Contains(m)) + isIncompatible = !ModUtils.CheckCompatibleSet(selectedMods.Value.Append(m)); + + if (isIncompatible) + incompatibleIcon.Show(); + else + incompatibleIcon.Hide(); + } + + public override ITooltip GetCustomTooltip() => new IncompatibilityDisplayingTooltip(); + + private class IncompatibilityDisplayingTooltip : ModButtonTooltip + { + private readonly OsuSpriteText incompatibleText; + + private readonly Bindable> incompatibleMods = new Bindable>(); + + [Resolved] + private Bindable ruleset { get; set; } + + public IncompatibilityDisplayingTooltip() + { + AddRange(new Drawable[] + { + incompatibleText = new OsuSpriteText + { + Margin = new MarginPadding { Top = 5 }, + Font = OsuFont.GetFont(weight: FontWeight.Regular), + Text = "Incompatible with:" + }, + new ModDisplay + { + Current = incompatibleMods, + ExpansionMode = ExpansionMode.AlwaysExpanded, + Scale = new Vector2(0.7f) + } + }); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + incompatibleText.Colour = colours.BlueLight; + } + + protected override void UpdateDisplay(Mod mod) + { + base.UpdateDisplay(mod); + + var incompatibleTypes = mod.IncompatibleMods; + + var allMods = ruleset.Value.CreateInstance().AllMods; + + incompatibleMods.Value = allMods.Where(m => m.GetType() != mod.GetType() && incompatibleTypes.Any(t => t.IsInstanceOfType(m))).Select(m => m.CreateInstance()).ToList(); + incompatibleText.Text = incompatibleMods.Value.Any() ? "Incompatible with:" : "Compatible with all mods"; + } + } + } +} diff --git a/osu.Game/Overlays/Mods/IncompatibleIcon.cs b/osu.Game/Overlays/Mods/IncompatibleIcon.cs new file mode 100644 index 0000000000..df134fe4a4 --- /dev/null +++ b/osu.Game/Overlays/Mods/IncompatibleIcon.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Mods +{ + public class IncompatibleIcon : VisibilityContainer, IHasTooltip + { + private Circle circle; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Size = new Vector2(20); + + State.Value = Visibility.Hidden; + Alpha = 0; + + InternalChildren = new Drawable[] + { + circle = new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray4, + }, + new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(0.6f), + Icon = FontAwesome.Solid.Slash, + Colour = Color4.White, + Shadow = true, + } + }; + } + + protected override void PopIn() + { + this.FadeIn(200, Easing.OutQuint); + circle.FlashColour(Color4.Red, 500, Easing.OutQuint); + this.ScaleTo(1.8f).Then().ScaleTo(1, 500, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(200, Easing.OutQuint); + this.ScaleTo(0.8f, 200, Easing.In); + } + + public LocalisableString TooltipText => "Incompatible with current selected mods"; + } +} diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs index d0bd24496a..979e2c8da3 100644 --- a/osu.Game/Overlays/Mods/ModButton.cs +++ b/osu.Game/Overlays/Mods/ModButton.cs @@ -23,7 +23,7 @@ namespace osu.Game.Overlays.Mods /// /// Represents a clickable button which can cycle through one of more mods. /// - public class ModButton : ModButtonEmpty, IHasTooltip + public class ModButton : ModButtonEmpty, IHasCustomTooltip { private ModIcon foregroundIcon; private ModIcon backgroundIcon; @@ -91,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) @@ -101,7 +101,7 @@ namespace osu.Game.Overlays.Mods .RotateTo(rotate_angle * direction) .RotateTo(0f, mod_switch_duration, mod_switch_easing); - Schedule(() => displayMod(newSelection)); + Schedule(() => DisplayMod(newSelection)); } } @@ -130,7 +130,8 @@ namespace osu.Game.Overlays.Mods } private Mod mod; - private readonly Container scaleContainer; + + protected readonly Container ButtonContent; public Mod Mod { @@ -154,7 +155,7 @@ namespace osu.Game.Overlays.Mods if (Mods.Length > 0) { - displayMod(Mods[0]); + DisplayMod(Mods[0]); } } } @@ -165,13 +166,13 @@ namespace osu.Game.Overlays.Mods protected override bool OnMouseDown(MouseDownEvent e) { - scaleContainer.ScaleTo(0.9f, 800, Easing.Out); + ButtonContent.ScaleTo(0.9f, 800, Easing.Out); return base.OnMouseDown(e); } protected override void OnMouseUp(MouseUpEvent e) { - scaleContainer.ScaleTo(1, 500, Easing.OutElastic); + ButtonContent.ScaleTo(1, 500, Easing.OutElastic); // only trigger the event if we are inside the area of the button if (Contains(e.ScreenSpaceMousePosition)) @@ -230,7 +231,7 @@ namespace osu.Game.Overlays.Mods public void Deselect() => changeSelectedIndex(-1); - private void displayMod(Mod mod) + protected virtual void DisplayMod(Mod mod) { if (backgroundIcon != null) backgroundIcon.Mod = foregroundIcon.Mod; @@ -282,13 +283,16 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.TopCentre, Children = new Drawable[] { - scaleContainer = new Container + ButtonContent = new Container { - Child = iconsContainer = new Container + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, + iconsContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + }, }, RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, @@ -305,8 +309,11 @@ namespace osu.Game.Overlays.Mods }, new HoverSounds() }; - Mod = mod; } + + public virtual ITooltip GetCustomTooltip() => new ModButtonTooltip(); + + public Mod TooltipContent => SelectedMod ?? Mods.FirstOrDefault(); } } diff --git a/osu.Game/Overlays/Mods/ModButtonTooltip.cs b/osu.Game/Overlays/Mods/ModButtonTooltip.cs new file mode 100644 index 0000000000..2f50e38a5a --- /dev/null +++ b/osu.Game/Overlays/Mods/ModButtonTooltip.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public class ModButtonTooltip : VisibilityContainer, ITooltip + { + private readonly OsuSpriteText descriptionText; + private readonly Box background; + + protected override Container Content { get; } + + public ModButtonTooltip() + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + Content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Left = 10, Right = 10, Top = 5, Bottom = 5 }, + Children = new Drawable[] + { + descriptionText = new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Regular), + }, + } + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = colours.Gray3; + descriptionText.Colour = colours.BlueLighter; + } + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + + private Mod lastMod; + + public void SetContent(Mod mod) + { + if (mod.Equals(lastMod)) + return; + + lastMod = mod; + + UpdateDisplay(mod); + } + + protected virtual void UpdateDisplay(Mod mod) + { + descriptionText.Text = mod.Description; + } + + public void Move(Vector2 pos) => Position = pos; + } +} diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index 6e289dc8aa..faad23a4e1 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -51,14 +51,14 @@ namespace osu.Game.Overlays.Mods if (m == null) return new ModButtonEmpty(); - return new ModButton(m) + return CreateModButton(m).With(b => { - SelectionChanged = mod => + b.SelectionChanged = mod => { ModButtonStateChanged(mod); Action?.Invoke(mod); - }, - }; + }; + }); }).ToArray(); modsLoadCts?.Cancel(); @@ -247,6 +247,8 @@ namespace osu.Game.Overlays.Mods Text = text }; + protected virtual ModButton CreateModButton(Mod mod) => new ModButton(mod); + /// /// Play out all remaining animations immediately to leave mods in a good (final) state. /// diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index e4aab978fc..ec7e49920c 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -74,6 +74,7 @@ namespace osu.Game.Overlays.Mods protected readonly ModSettingsContainer ModSettingsContainer; + [Cached] public readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); private Bindable>> availableMods; @@ -128,7 +129,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[] @@ -404,18 +405,18 @@ namespace osu.Game.Overlays.Mods switch (e.Key) { case Key.Number1: - DeselectAllButton.Click(); + DeselectAllButton.TriggerClick(); return true; case Key.Number2: - CloseButton.Click(); + CloseButton.TriggerClick(); return true; } return base.OnKeyDown(e); } - public override bool OnPressed(GlobalAction action) => false; // handled by back button + public override bool OnPressed(KeyBindingPressEvent e) => false; // handled by back button private void updateAvailableMods() { @@ -429,7 +430,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(); diff --git a/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs similarity index 51% rename from osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs rename to osu.Game/Overlays/Mods/UserModSelectOverlay.cs index db76581108..161f89c2eb 100644 --- a/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs @@ -5,7 +5,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Overlays.Mods { - public class LocalPlayerModSelectOverlay : ModSelectOverlay + public class UserModSelectOverlay : ModSelectOverlay { protected override void OnModSelected(Mod mod) { @@ -14,5 +14,17 @@ namespace osu.Game.Overlays.Mods foreach (var section in ModSectionsContainer.Children) section.DeselectTypes(mod.IncompatibleMods, true, mod); } + + protected override ModSection CreateModSection(ModType type) => new UserModSection(type); + + private class UserModSection : ModSection + { + public UserModSection(ModType type) + : base(type) + { + } + + protected override ModButton CreateModButton(Mod mod) => new IncompatibilityDisplayingModButton(mod); + } } } diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs index f06e02e5e1..dba4bf926f 100644 --- a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Bindings; @@ -26,23 +27,23 @@ namespace osu.Game.Overlays.Music [Resolved(canBeNull: true)] private OnScreenDisplay onScreenDisplay { get; set; } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { if (beatmap.Disabled) return false; - switch (action) + switch (e.Action) { case GlobalAction.MusicPlay: // use previous state as TogglePause may not update the track's state immediately (state update is run on the audio thread see https://github.com/ppy/osu/issues/9880#issuecomment-674668842) bool wasPlaying = musicController.IsPlaying; if (musicController.TogglePause()) - onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? "Pause track" : "Play track", action)); + onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? "Pause track" : "Play track", e.Action)); return true; case GlobalAction.MusicNext: - musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast("Next track", action))); + musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast("Next track", e.Action))); return true; @@ -52,11 +53,11 @@ namespace osu.Game.Overlays.Music switch (res) { case PreviousTrackResult.Restart: - onScreenDisplay?.Display(new MusicActionToast("Restart track", action)); + onScreenDisplay?.Display(new MusicActionToast("Restart track", e.Action)); break; case PreviousTrackResult.Previous: - onScreenDisplay?.Display(new MusicActionToast("Previous track", action)); + onScreenDisplay?.Display(new MusicActionToast("Previous track", e.Action)); break; } }); @@ -67,7 +68,7 @@ namespace osu.Game.Overlays.Music return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index 571b14428e..ef25de77c6 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -38,7 +38,7 @@ namespace osu.Game.Overlays.Music { Padding = new MarginPadding { Left = 5 }; - FilterTerms = item.Metadata.SearchableTerms; + FilterTerms = item.Metadata.GetSearchableTerms(); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index a15f80ca21..8fd50c3df2 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -400,35 +400,38 @@ namespace osu.Game.Overlays NextTrack(); } - private bool allowRateAdjustments; + private bool allowTrackAdjustments; /// - /// Whether mod rate adjustments are allowed to be applied. + /// Whether mod track adjustments are allowed to be applied. /// - public bool AllowRateAdjustments + public bool AllowTrackAdjustments { - get => allowRateAdjustments; + get => allowTrackAdjustments; set { - if (allowRateAdjustments == value) + if (allowTrackAdjustments == value) return; - allowRateAdjustments = value; + allowTrackAdjustments = value; ResetTrackAdjustments(); } } /// - /// Resets the speed adjustments currently applied on and applies the mod adjustments if is true. + /// Resets the adjustments currently applied on and applies the mod adjustments if is true. /// /// - /// Does not reset speed adjustments applied directly to the beatmap track. + /// Does not reset any adjustments applied directly to the beatmap track. /// public void ResetTrackAdjustments() { - CurrentTrack.ResetSpeedAdjustments(); + CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Balance); + CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Frequency); + CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Tempo); + CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Volume); - if (allowRateAdjustments) + if (allowTrackAdjustments) { foreach (var mod in mods.Value.OfType()) mod.ApplyToTrack(CurrentTrack); diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs index 599b45fa78..68d0704825 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -14,6 +14,7 @@ using osu.Framework.Platform; 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.Responses; namespace osu.Game.Overlays.News @@ -28,6 +29,7 @@ namespace osu.Game.Overlays.News private TextFlowContainer main; public NewsCard(APINewsPost post) + : base(HoverSampleSet.Submit) { this.post = post; @@ -121,12 +123,8 @@ namespace osu.Game.Overlays.News main.AddText(post.Author, t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)); } - private class DateContainer : CircularContainer, IHasCustomTooltip + private class DateContainer : CircularContainer, IHasCustomTooltip { - public ITooltip GetCustomTooltip() => new DateTooltip(); - - public object TooltipContent => date; - private readonly DateTimeOffset date; public DateContainer(DateTimeOffset date) @@ -160,6 +158,10 @@ namespace osu.Game.Overlays.News } protected override bool OnClick(ClickEvent e) => true; // Protects the NewsCard from clicks while hovering DateContainer + + ITooltip IHasCustomTooltip.GetCustomTooltip() => new DateTooltip(); + + DateTimeOffset IHasCustomTooltip.TooltipContent => date; } } } diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index 94bfd62c32..35e3c7755d 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -4,20 +4,23 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.News { public class NewsHeader : BreadcrumbControlOverlayHeader { - private const string front_page_string = "frontpage"; + public static LocalisableString FrontPageString => NewsStrings.IndexTitleInfo; public Action ShowFrontPage; - private readonly Bindable article = new Bindable(null); + private readonly Bindable article = new Bindable(); public NewsHeader() { - TabControl.AddItem(front_page_string); + TabControl.AddItem(FrontPageString); article.BindValueChanged(onArticleChanged, true); } @@ -28,7 +31,7 @@ namespace osu.Game.Overlays.News Current.BindValueChanged(e => { - if (e.NewValue == front_page_string) + if (e.NewValue == FrontPageString) ShowFrontPage?.Invoke(); }); } @@ -49,7 +52,7 @@ namespace osu.Game.Overlays.News } else { - Current.Value = front_page_string; + Current.Value = FrontPageString; } } @@ -61,8 +64,8 @@ namespace osu.Game.Overlays.News { public NewsHeaderTitle() { - Title = "news"; - Description = "get up-to-date on community happenings"; + Title = PageTitleStrings.MainNewsControllerDefault; + Description = NamedOverlayComponentStrings.NewsDescription; IconTexture = "Icons/Hexacons/news"; } } diff --git a/osu.Game/Overlays/News/Sidebar/MonthSection.cs b/osu.Game/Overlays/News/Sidebar/MonthSection.cs index b300a755f9..948f312f15 100644 --- a/osu.Game/Overlays/News/Sidebar/MonthSection.cs +++ b/osu.Game/Overlays/News/Sidebar/MonthSection.cs @@ -15,13 +15,18 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using System.Diagnostics; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Platform; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.News.Sidebar { public class MonthSection : CompositeDrawable { private const int animation_duration = 250; + private Sample sampleOpen; + private Sample sampleClose; public readonly BindableBool Expanded = new BindableBool(); @@ -51,6 +56,21 @@ namespace osu.Game.Overlays.News.Sidebar } } }; + + Expanded.ValueChanged += expanded => + { + if (expanded.NewValue) + sampleOpen?.Play(); + else + sampleClose?.Play(); + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); + sampleClose = audio.Samples.Get(@"UI/dropdown-close"); } private class DropdownHeader : OsuClickableContainer @@ -104,6 +124,7 @@ namespace osu.Game.Overlays.News.Sidebar private readonly APINewsPost post; public PostButton(APINewsPost post) + : base(HoverSampleSet.Submit) { this.post = post; diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index b26e17b34c..8809dec642 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -8,8 +8,8 @@ using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Notifications; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.Containers; -using System; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Framework.Threading; @@ -24,21 +24,20 @@ namespace osu.Game.Overlays public LocalisableString Title => NotificationsStrings.HeaderTitle; public LocalisableString Description => NotificationsStrings.HeaderDescription; - private const float width = 320; + public const float WIDTH = 320; public const float TRANSITION_LENGTH = 600; private FlowContainer sections; - /// - /// Provide a source for the toolbar height. - /// - public Func GetToolbarHeight; + [Resolved] + private AudioManager audio { get; set; } [BackgroundDependencyLoader] private void load() { - Width = width; + X = WIDTH; + Width = WIDTH; RelativeSizeAxes = Axes.Y; Children = new Drawable[] @@ -103,14 +102,18 @@ namespace osu.Game.Overlays private int runningDepth; - private void notificationClosed() => updateCounts(); - private readonly Scheduler postScheduler = new Scheduler(); public override bool IsPresent => base.IsPresent || postScheduler.HasPendingTasks; private bool processingPosts = true; + private double? lastSamplePlayback; + + /// + /// Post a new notification for display. + /// + /// The notification to display. public void Post(Notification notification) => postScheduler.Add(() => { ++runningDepth; @@ -129,11 +132,13 @@ namespace osu.Game.Overlays Show(); updateCounts(); + playDebouncedSample(notification.PopInSampleName); }); protected override void Update() { base.Update(); + if (processingPosts) postScheduler.Update(); } @@ -152,10 +157,28 @@ namespace osu.Game.Overlays markAllRead(); - this.MoveToX(width, TRANSITION_LENGTH, Easing.OutQuint); + this.MoveToX(WIDTH, TRANSITION_LENGTH, Easing.OutQuint); this.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); } + private void notificationClosed() + { + updateCounts(); + + // this debounce is currently shared between popin/popout sounds, which means one could potentially not play when the user is expecting it. + // popout is constant across all notification types, and should therefore be handled using playback concurrency instead, but seems broken at the moment. + playDebouncedSample("UI/overlay-pop-out"); + } + + private void playDebouncedSample(string sampleName) + { + if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME) + { + audio.Samples.Get(sampleName)?.Play(); + lastSamplePlayback = Time.Current; + } + } + private void updateCounts() { UnreadCount.Value = sections.Select(c => c.UnreadCount).Sum(); @@ -167,12 +190,5 @@ namespace osu.Game.Overlays updateCounts(); } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; - } } } diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index d1a97c74b2..44203e8ee7 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -3,20 +3,18 @@ using System; 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.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; -using osu.Game.Graphics; -using osuTK; -using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays.Notifications { @@ -42,10 +40,7 @@ namespace osu.Game.Overlays.Notifications /// public virtual bool DisplayOnTop => true; - private Sample samplePopIn; - private Sample samplePopOut; - protected virtual string PopInSampleName => "UI/notification-pop-in"; - protected virtual string PopOutSampleName => "UI/overlay-pop-out"; // TODO: replace with a unique sample? + public virtual string PopInSampleName => "UI/notification-pop-in"; protected NotificationLight Light; private readonly CloseButton closeButton; @@ -114,7 +109,7 @@ namespace osu.Game.Overlays.Notifications closeButton = new CloseButton { Alpha = 0, - Action = () => Close(), + Action = Close, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Margin = new MarginPadding @@ -127,13 +122,6 @@ namespace osu.Game.Overlays.Notifications }); } - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - samplePopIn = audio.Samples.Get(PopInSampleName); - samplePopOut = audio.Samples.Get(PopOutSampleName); - } - protected override bool OnHover(HoverEvent e) { closeButton.FadeIn(75); @@ -158,8 +146,6 @@ namespace osu.Game.Overlays.Notifications { base.LoadComplete(); - samplePopIn?.Play(); - this.FadeInFromZero(200); NotificationContent.MoveToX(DrawSize.X); NotificationContent.MoveToX(0, 500, Easing.OutQuint); @@ -167,15 +153,12 @@ namespace osu.Game.Overlays.Notifications public bool WasClosed; - public virtual void Close(bool playSound = true) + public virtual void Close() { if (WasClosed) return; WasClosed = true; - if (playSound) - samplePopOut?.Play(); - Closed?.Invoke(); this.FadeOut(100); Expire(); diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index 2316199049..a23ff07a64 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -110,12 +110,7 @@ namespace osu.Game.Overlays.Notifications private void clearAll() { - bool first = true; - notifications.Children.ForEach(c => - { - c.Close(first); - first = false; - }); + notifications.Children.ForEach(c => c.Close()); } protected override void Update() diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 703c14af2b..3105ecd742 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -150,12 +150,12 @@ namespace osu.Game.Overlays.Notifications colourCancelled = colours.Red; } - public override void Close(bool playSound = true) + public override void Close() { switch (State) { case ProgressNotificationState.Cancelled: - base.Close(playSound); + base.Close(); break; case ProgressNotificationState.Active: diff --git a/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs index 13c9c5a02d..faab4ed472 100644 --- a/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs +++ b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs @@ -7,7 +7,7 @@ namespace osu.Game.Overlays.Notifications { public class SimpleErrorNotification : SimpleNotification { - protected override string PopInSampleName => "UI/error-notification-pop-in"; + public override string PopInSampleName => "UI/error-notification-pop-in"; public SimpleErrorNotification() { diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index f88be91c01..5619d7b38a 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -55,11 +55,6 @@ namespace osu.Game.Overlays protected override string PopInSampleName => "UI/now-playing-pop-in"; protected override string PopOutSampleName => "UI/now-playing-pop-out"; - /// - /// Provide a source for the toolbar height. - /// - public Func GetToolbarHeight; - [Resolved] private MusicController musicController { get; set; } @@ -246,7 +241,6 @@ namespace osu.Game.Overlays base.UpdateAfterChildren(); Height = dragContainer.Height; - dragContainer.Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; } protected override void Update() diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 51214fe460..198aa1438a 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -29,7 +29,7 @@ namespace osu.Game.Overlays.OSD private Sample sampleChange; public TrackedSettingToast(SettingDescription description) - : base(description.Name, description.Value, description.Shortcut) + : base(description.Name.ToString(), description.Value.ToString(), description.Shortcut.ToString()) { FillFlowContainer optionLights; diff --git a/osu.Game/Overlays/OverlayColourProvider.cs b/osu.Game/Overlays/OverlayColourProvider.cs index abd1e43f25..e7b3e6d873 100644 --- a/osu.Game/Overlays/OverlayColourProvider.cs +++ b/osu.Game/Overlays/OverlayColourProvider.cs @@ -14,9 +14,11 @@ 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); + public static OverlayColourProvider Plum { get; } = new OverlayColourProvider(OverlayColourScheme.Plum); public OverlayColourProvider(OverlayColourScheme colourScheme) { @@ -51,7 +53,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,16 +68,22 @@ 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; case OverlayColourScheme.Blue: return 200 / 360f; + + case OverlayColourScheme.Plum: + return 320 / 360f; } } } @@ -85,8 +93,10 @@ namespace osu.Game.Overlays Red, Pink, Orange, + Lime, Green, Purple, - Blue + Blue, + Plum, } } diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs index c2268ff43c..d7a3b052ae 100644 --- a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs @@ -12,7 +12,6 @@ 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; @@ -101,32 +100,15 @@ namespace osu.Game.Overlays } } - [LocalisableEnum(typeof(OverlayPanelDisplayStyleEnumLocalisationMapper))] public enum OverlayPanelDisplayStyle { + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ViewModeCard))] Card, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ViewModeList))] List, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ViewModeBrick))] 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/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index 62ebee7677..180a288729 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -3,11 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -119,12 +122,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 }, } @@ -144,8 +147,8 @@ namespace osu.Game.Overlays.Profile.Header private void updateDisplay(User user) { - hiddenDetailGlobal.Content = user?.Statistics?.GlobalRank?.ToString("\\##,##0") ?? "-"; - hiddenDetailCountry.Content = user?.Statistics?.CountryRank?.ToString("\\##,##0") ?? "-"; + hiddenDetailGlobal.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + hiddenDetailCountry.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs index 527c70685f..b4a5d5e31b 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs @@ -2,11 +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 @@ -15,21 +19,32 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly BindableBool DetailsVisible = new BindableBool(); - public override LocalisableString 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 db94762efd..8f66120055 100644 --- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -5,6 +5,7 @@ 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 @@ -13,7 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override LocalisableString 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 a0b8ef0f11..1deed1a748 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs @@ -11,6 +11,7 @@ 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 @@ -19,13 +20,13 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public LocalisableString TooltipText { get; } + public LocalisableString TooltipText { get; private set; } private OsuSpriteText levelText; public LevelBadge() { - TooltipText = "level"; + TooltipText = UsersStrings.ShowStatsLevel("0"); } [BackgroundDependencyLoader] @@ -53,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 528b05a80a..2c8c421eba 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -10,6 +11,7 @@ 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; @@ -26,7 +28,7 @@ namespace osu.Game.Overlays.Profile.Header.Components public LevelProgressBar() { - TooltipText = "progress to next level"; + TooltipText = UsersStrings.ShowStatsLevelProgress; } [BackgroundDependencyLoader] @@ -60,7 +62,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private void updateProgress(User user) { levelProgressBar.Length = user?.Statistics?.Level.Progress / 100f ?? 0; - levelProgressText.Text = user?.Statistics?.Level.Progress.ToString("0'%'"); + levelProgressText.Text = user?.Statistics?.Level.Progress.ToLocalisableString("0'%'"); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs index ae3d024fbf..5cdf3a5ef9 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs @@ -5,6 +5,7 @@ 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 @@ -13,7 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override LocalisableString 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 4c2cc768ce..07f1f1c3ed 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs @@ -8,6 +8,7 @@ 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; @@ -17,7 +18,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override LocalisableString 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..5ef8482b47 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,12 +17,12 @@ 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; } - public string Content + public LocalisableString Content { set => content.Text = value; } diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs index aa7cb8636a..1a40944632 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs @@ -7,6 +7,7 @@ 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 @@ -31,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/ProfileHeaderStatisticsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs index b65d5e2329..b098f9f840 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.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.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -46,6 +47,6 @@ namespace osu.Game.Overlays.Profile.Header.Components protected abstract IconUsage Icon { get; } - protected void SetValue(int value) => drawableText.Text = value.ToString("#,##0"); + protected void SetValue(int value) => drawableText.Text = value.ToLocalisableString("#,##0"); } } diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs index ad91e491ef..ca5f26e375 100644 --- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs +++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs @@ -6,9 +6,11 @@ using System.Collections.Generic; using System.Linq; using Humanizer; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; 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 +29,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) }); } @@ -58,41 +60,14 @@ namespace osu.Game.Overlays.Profile.Header.Components placeholder.FadeIn(FADE_DURATION, Easing.Out); } - protected override object GetTooltipContent(int index, int rank) + protected override UserGraphTooltipContent GetTooltipContent(int index, int rank) { var days = ranked_days - index + 1; - return new TooltipDisplayContent - { - Rank = $"#{rank:N0}", - Time = days == 0 ? "now" : $"{"day".ToQuantity(days)} ago" - }; - } - - protected override UserGraphTooltip GetTooltip() => new RankGraphTooltip(); - - private class RankGraphTooltip : UserGraphTooltip - { - public RankGraphTooltip() - : base("Global Ranking") - { - } - - public override bool SetContent(object content) - { - if (!(content is TooltipDisplayContent info)) - return false; - - Counter.Text = info.Rank; - BottomText.Text = info.Time; - return true; - } - } - - private class TooltipDisplayContent - { - public string Rank; - public string Time; + return new UserGraphTooltipContent( + UsersStrings.ShowRankGlobalSimple, + rank.ToLocalisableString("\\##,##0"), + days == 0 ? "now" : $"{"day".ToQuantity(days)} ago"); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs index 9a43997030..77f0378762 100644 --- a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs +++ b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs @@ -10,6 +10,7 @@ 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 { @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly FillFlowContainer iconContainer; private readonly CircularContainer content; - public LocalisableString 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..8ca6961950 100644 --- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs @@ -4,13 +4,16 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; 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 +103,7 @@ namespace osu.Game.Overlays.Profile.Header }, medalInfo = new OverlinedInfoContainer { - Title = "Medals", + Title = UsersStrings.ShowStatsMedals, LineColour = colours.GreenLight, }, ppInfo = new OverlinedInfoContainer @@ -151,12 +154,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, }, } @@ -171,13 +174,13 @@ namespace osu.Game.Overlays.Profile.Header private void updateDisplay(User user) { medalInfo.Content = user?.Achievements?.Length.ToString() ?? "0"; - ppInfo.Content = user?.Statistics?.PP?.ToString("#,##0") ?? "0"; + ppInfo.Content = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; foreach (var scoreRankInfo in scoreRankInfos) scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; - detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToString("\\##,##0") ?? "-"; - detailCountryRank.Content = user?.Statistics?.CountryRank?.ToString("\\##,##0") ?? "-"; + detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + detailCountryRank.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; rankGraph.Statistics.Value = user?.Statistics; } @@ -188,7 +191,7 @@ namespace osu.Game.Overlays.Profile.Header public int RankCount { - set => rankCount.Text = value.ToString("#,##0"); + set => rankCount.Text = value.ToLocalisableString("#,##0"); } public ScoreRankInfo(ScoreRank rank) diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index d751424367..f5720cffb0 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -4,14 +4,17 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; 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,7 +61,7 @@ namespace osu.Game.Overlays.Profile.Header Origin = Anchor.CentreLeft, Children = new Drawable[] { - avatar = new UpdateableAvatar(openOnClick: false, showGuestOnNull: false) + avatar = new UpdateableAvatar(isInteractive: false, showGuestOnNull: false) { Size = new Vector2(avatar_size), Masking = true, @@ -179,19 +182,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.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsHitAccuracy, user.Statistics.DisplayAccuracy)); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsPlayCount, user.Statistics.PlayCount.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalScore, user.Statistics.TotalScore.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalHits, user.Statistics.TotalHits.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsMaximumCombo, user.Statistics.MaxCombo.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsReplaysWatchedByOthers, user.Statistics.ReplaysWatched.ToLocalisableString("#,##0"))); } } private class UserStatsLine : Container { - public UserStatsLine(string left, string right) + public UserStatsLine(LocalisableString left, LocalisableString 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/BeatmapMetadataContainer.cs b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs index 67a976fe6f..7812a81f30 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Profile.Sections { @@ -14,11 +15,12 @@ namespace osu.Game.Overlays.Profile.Sections /// public abstract class BeatmapMetadataContainer : OsuHoverContainer { - private readonly BeatmapInfo beatmap; + private readonly BeatmapInfo beatmapInfo; - protected BeatmapMetadataContainer(BeatmapInfo beatmap) + protected BeatmapMetadataContainer(BeatmapInfo beatmapInfo) + : base(HoverSampleSet.Submit) { - this.beatmap = beatmap; + this.beatmapInfo = beatmapInfo; AutoSizeAxes = Axes.Both; } @@ -28,19 +30,19 @@ namespace osu.Game.Overlays.Profile.Sections { Action = () => { - if (beatmap.OnlineBeatmapID != null) - beatmapSetOverlay?.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value); - else if (beatmap.BeatmapSet?.OnlineBeatmapSetID != null) - beatmapSetOverlay?.FetchAndShowBeatmapSet(beatmap.BeatmapSet.OnlineBeatmapSetID.Value); + if (beatmapInfo.OnlineBeatmapID != null) + beatmapSetOverlay?.FetchAndShowBeatmap(beatmapInfo.OnlineBeatmapID.Value); + else if (beatmapInfo.BeatmapSet?.OnlineBeatmapSetID != null) + beatmapSetOverlay?.FetchAndShowBeatmapSet(beatmapInfo.BeatmapSet.OnlineBeatmapSetID.Value); }; Child = new FillFlowContainer { AutoSizeAxes = Axes.Both, - Children = CreateText(beatmap), + Children = CreateText(beatmapInfo), }; } - protected abstract Drawable[] CreateText(BeatmapInfo beatmap); + protected abstract Drawable[] CreateText(BeatmapInfo beatmapInfo); } } diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index fe9c710bcc..8657e356c9 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; @@ -45,11 +46,11 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps case BeatmapSetType.Loved: return user.LovedBeatmapsetCount; - case BeatmapSetType.RankedAndApproved: - return user.RankedAndApprovedBeatmapsetCount; + case BeatmapSetType.Ranked: + return user.RankedBeatmapsetCount; - case BeatmapSetType.Unranked: - return user.UnrankedBeatmapsetCount; + case BeatmapSetType.Pending: + return user.PendingBeatmapsetCount; default: return 0; diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs index c283de42f3..af6ab4aad1 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.Ranked, User, UsersStrings.ShowExtraBeatmapsRankedTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, UsersStrings.ShowExtraBeatmapsLovedTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Pending, User, UsersStrings.ShowExtraBeatmapsPendingTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, UsersStrings.ShowExtraBeatmapsGraveyardTitle) }; } } diff --git a/osu.Game/Overlays/Profile/Sections/CounterPill.cs b/osu.Game/Overlays/Profile/Sections/CounterPill.cs index ca8abcfe5a..bd6cb4d09b 100644 --- a/osu.Game/Overlays/Profile/Sections/CounterPill.cs +++ b/osu.Game/Overlays/Profile/Sections/CounterPill.cs @@ -8,6 +8,7 @@ using osu.Game.Graphics; using osu.Framework.Bindables; using osu.Game.Graphics.Sprites; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; namespace osu.Game.Overlays.Profile.Sections { @@ -48,7 +49,7 @@ namespace osu.Game.Overlays.Profile.Sections private void onCurrentChanged(ValueChangedEvent value) { - counter.Text = value.NewValue.ToString("N0"); + counter.Text = value.NewValue.ToLocalisableString("N0"); } } } 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 6f1869966a..2c6fa76ca4 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 { @@ -21,12 +22,12 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private const int cover_width = 100; private const int corner_radius = 6; - private readonly BeatmapInfo beatmap; + private readonly BeatmapInfo beatmapInfo; private readonly int playCount; - public DrawableMostPlayedBeatmap(BeatmapInfo beatmap, int playCount) + public DrawableMostPlayedBeatmap(BeatmapInfo beatmapInfo, int playCount) { - this.beatmap = beatmap; + this.beatmapInfo = beatmapInfo; this.playCount = playCount; RelativeSizeAxes = Axes.X; @@ -45,7 +46,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { RelativeSizeAxes = Axes.Y, Width = cover_width, - BeatmapSet = beatmap.BeatmapSet, + BeatmapSet = beatmapInfo.BeatmapSet, }, new Container { @@ -76,7 +77,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical Direction = FillDirection.Vertical, Children = new Drawable[] { - new MostPlayedBeatmapMetadataContainer(beatmap), + new MostPlayedBeatmapMetadataContainer(beatmapInfo), new LinkFlowContainer(t => { t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); @@ -88,7 +89,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical }.With(d => { d.AddText("mapped by "); - d.AddUserLink(beatmap.Metadata.Author); + d.AddUserLink(beatmapInfo.Metadata.Author); }), } }, @@ -119,23 +120,23 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class MostPlayedBeatmapMetadataContainer : BeatmapMetadataContainer { - public MostPlayedBeatmapMetadataContainer(BeatmapInfo beatmap) - : base(beatmap) + public MostPlayedBeatmapMetadataContainer(BeatmapInfo beatmapInfo) + : base(beatmapInfo) { } - protected override Drawable[] CreateText(BeatmapInfo beatmap) => new Drawable[] + protected override Drawable[] CreateText(BeatmapInfo beatmapInfo) => new Drawable[] { new OsuSpriteText { Text = new RomanisableString( - $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} [{beatmap.Version}] ", - $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} [{beatmap.Version}] "), + $"{beatmapInfo.Metadata.TitleUnicode ?? beatmapInfo.Metadata.Title} [{beatmapInfo.Version}] ", + $"{beatmapInfo.Metadata.Title ?? beatmapInfo.Metadata.TitleUnicode} [{beatmapInfo.Version}] "), Font = OsuFont.GetFont(weight: FontWeight.Bold) }, new OsuSpriteText { - Text = "by " + new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist), + Text = "by " + new RomanisableString(beatmapInfo.Metadata.ArtistUnicode, beatmapInfo.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.Regular) }, }; @@ -143,7 +144,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class PlayCountText : CompositeDrawable, IHasTooltip { - public LocalisableString 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..a75235359a 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs @@ -9,9 +9,11 @@ using System.Linq; using osu.Game.Graphics.Sprites; using osu.Framework.Utils; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; 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 +44,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; @@ -169,7 +171,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical Origin = Anchor.CentreRight, RelativePositionAxes = Axes.Y, Margin = new MarginPadding { Right = 3 }, - Text = value.ToString("N0"), + Text = value.ToLocalisableString("N0"), Font = OsuFont.GetFont(size: 12), Y = y }); @@ -192,7 +194,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { Origin = Anchor.CentreLeft, RelativePositionAxes = Axes.X, - Text = value.ToString("MMM yyyy"), + Text = value.ToLocalisableString("MMM yyyy"), Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Rotation = 45, X = x 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..61f77cd6ff 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs @@ -5,13 +5,15 @@ using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; +using osu.Framework.Extensions.LocalisationExtensions; +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,51 +21,17 @@ 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; } protected override float GetDataPointHeight(long playCount) => playCount; - protected override UserGraphTooltip GetTooltip() => new HistoryGraphTooltip(tooltipCounterName); - - protected override object GetTooltipContent(DateTime date, long playCount) - { - return new TooltipDisplayContent - { - Name = tooltipCounterName, - Count = playCount.ToString("N0"), - Date = date.ToString("MMMM yyyy") - }; - } - - protected class HistoryGraphTooltip : UserGraphTooltip - { - private readonly string tooltipCounterName; - - public HistoryGraphTooltip(string tooltipCounterName) - : base(tooltipCounterName) - { - this.tooltipCounterName = tooltipCounterName; - } - - public override bool SetContent(object content) - { - if (!(content is TooltipDisplayContent info) || info.Name != tooltipCounterName) - return false; - - Counter.Text = info.Count; - BottomText.Text = info.Date; - return true; - } - } - - private class TooltipDisplayContent - { - public string Name; - public string Count; - public string Date; - } + protected override UserGraphTooltipContent GetTooltipContent(DateTime date, long playCount) => + new UserGraphTooltipContent( + tooltipCounterName, + playCount.ToLocalisableString("N0"), + date.ToLocalisableString("MMMM yyyy")); } } 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..762716efab 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -12,6 +12,9 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Users; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Game.Resources.Localisation.Web; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections.Kudosu { @@ -37,7 +40,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"); @@ -53,10 +56,10 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu public new int Count { - set => valueText.Text = value.ToString("N0"); + set => valueText.Text = value.ToLocalisableString("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/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 713303285a..3561e9700e 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -78,7 +78,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Spacing = new Vector2(0, 2), Children = new Drawable[] { - new ScoreBeatmapMetadataContainer(Score.Beatmap), + new ScoreBeatmapMetadataContainer(Score.BeatmapInfo), new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -88,7 +88,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { new OsuSpriteText { - Text = $"{Score.Beatmap.Version}", + Text = $"{Score.BeatmapInfo.Version}", Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), Colour = colours.Yellow }, @@ -245,27 +245,27 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks private class ScoreBeatmapMetadataContainer : BeatmapMetadataContainer { - public ScoreBeatmapMetadataContainer(BeatmapInfo beatmap) - : base(beatmap) + public ScoreBeatmapMetadataContainer(BeatmapInfo beatmapInfo) + : base(beatmapInfo) { } - protected override Drawable[] CreateText(BeatmapInfo beatmap) => new Drawable[] + protected override Drawable[] CreateText(BeatmapInfo beatmapInfo) => new Drawable[] { new OsuSpriteText { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Text = new RomanisableString( - $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} ", - $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} "), + $"{beatmapInfo.Metadata.TitleUnicode ?? beatmapInfo.Metadata.Title} ", + $"{beatmapInfo.Metadata.Title ?? beatmapInfo.Metadata.TitleUnicode} "), Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) }, new OsuSpriteText { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "by " + new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist), + Text = "by " + new RomanisableString(beatmapInfo.Metadata.ArtistUnicode, beatmapInfo.Metadata.Artist), Font = OsuFont.GetFont(size: 12, italics: true) }, }; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index 3afa79e59e..f77464ecb9 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Extensions.LocalisationExtensions; 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 +53,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks new OsuSpriteText { Font = OsuFont.GetFont(size: 12), - Text = $@"weighted {weight:0%}" + Text = UsersStrings.ShowExtraTopRanksPpWeight(weight.ToLocalisableString("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..182221eea7 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; @@ -23,7 +24,7 @@ namespace osu.Game.Overlays.Profile /// /// Type of data to be used for X-axis of the graph. /// Type of data to be used for Y-axis of the graph. - public abstract class UserGraph : Container, IHasCustomTooltip + public abstract class UserGraph : Container, IHasCustomTooltip { protected const float FADE_DURATION = 150; @@ -117,11 +118,9 @@ namespace osu.Game.Overlays.Profile protected virtual void ShowGraph() => graph.FadeIn(FADE_DURATION, Easing.Out); protected virtual void HideGraph() => graph.FadeOut(FADE_DURATION, Easing.Out); - public ITooltip GetCustomTooltip() => GetTooltip(); + public ITooltip GetCustomTooltip() => new UserGraphTooltip(); - protected abstract UserGraphTooltip GetTooltip(); - - public object TooltipContent + public UserGraphTooltipContent TooltipContent { get { @@ -133,7 +132,7 @@ namespace osu.Game.Overlays.Profile } } - protected abstract object GetTooltipContent(TKey key, TValue value); + protected abstract UserGraphTooltipContent GetTooltipContent(TKey key, TValue value); protected class UserLineGraph : LineGraph { @@ -206,12 +205,12 @@ namespace osu.Game.Overlays.Profile } } - protected abstract class UserGraphTooltip : VisibilityContainer, ITooltip + private class UserGraphTooltip : VisibilityContainer, ITooltip { - protected readonly OsuSpriteText Counter, BottomText; + protected readonly OsuSpriteText Label, Counter, BottomText; private readonly Box background; - protected UserGraphTooltip(string tooltipCounterName) + public UserGraphTooltip() { AutoSizeAxes = Axes.Both; Masking = true; @@ -237,10 +236,9 @@ namespace osu.Game.Overlays.Profile Spacing = new Vector2(3, 0), Children = new Drawable[] { - new OsuSpriteText + Label = new OsuSpriteText { Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = tooltipCounterName }, Counter = new OsuSpriteText { @@ -267,7 +265,12 @@ namespace osu.Game.Overlays.Profile background.Colour = colours.Gray1; } - public abstract bool SetContent(object content); + public void SetContent(UserGraphTooltipContent content) + { + Label.Text = content.Name; + Counter.Text = content.Count; + BottomText.Text = content.Time; + } private bool instantMove = true; @@ -291,4 +294,19 @@ namespace osu.Game.Overlays.Profile protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); } } + + public class UserGraphTooltipContent + { + // todo: could use init-only properties on C# 9 which read better than a constructor. + public LocalisableString Name { get; } + public LocalisableString Count { get; } + public LocalisableString Time { get; } + + public UserGraphTooltipContent(LocalisableString name, LocalisableString count, LocalisableString time) + { + Name = name; + Count = count; + Time = time; + } + } } 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/RankingsOverlayHeader.cs b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs index 92e22f5873..417b33ddf6 100644 --- a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs +++ b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.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 osu.Framework.Graphics; using osu.Framework.Bindables; +using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Framework.Graphics; using osu.Game.Rulesets; using osu.Game.Users; @@ -29,18 +31,10 @@ namespace osu.Game.Overlays.Rankings { public RankingsTitle() { - Title = "ranking"; - Description = "find out who's the best right now"; + Title = PageTitleStrings.MainRankingControllerDefault; + Description = NamedOverlayComponentStrings.RankingsDescription; IconTexture = "Icons/Hexacons/rankings"; } } } - - public enum RankingsScope - { - Performance, - Spotlights, - Score, - Country - } } diff --git a/osu.Game/Overlays/Rankings/RankingsScope.cs b/osu.Game/Overlays/Rankings/RankingsScope.cs new file mode 100644 index 0000000000..e660c2898a --- /dev/null +++ b/osu.Game/Overlays/Rankings/RankingsScope.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Rankings +{ + public enum RankingsScope + { + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypePerformance))] + Performance, + + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCharts))] + Spotlights, + + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeScore))] + Score, + + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCountry))] + Country + } +} diff --git a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs index c0bbf46e30..f05795b2a2 100644 --- a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs +++ b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs @@ -1,19 +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.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.Rankings { public class RankingsSortTabControl : OverlaySortTabControl { public RankingsSortTabControl() { - Title = "Show"; + Title = RankingsStrings.FilterTitle.ToUpper(); } } public enum RankingsSortCriteria { + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.All))] All, + + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.Friends))] Friends } } diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index 422373d099..0f071883ca 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -15,6 +15,10 @@ using System; using System.Collections.Generic; using osu.Framework.Graphics.UserInterface; using osu.Game.Online.API.Requests; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings { @@ -46,6 +50,7 @@ namespace osu.Game.Overlays.Rankings { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] { background = new Box @@ -90,10 +95,10 @@ namespace osu.Game.Overlays.Rankings Margin = new MarginPadding { Bottom = 5 }, Children = new Drawable[] { - startDateColumn = new InfoColumn(@"Start Date"), - endDateColumn = new InfoColumn(@"End Date"), - mapCountColumn = new InfoColumn(@"Map Count"), - participantsColumn = new InfoColumn(@"Participants") + startDateColumn = new InfoColumn(RankingsStrings.SpotlightStartDate), + endDateColumn = new InfoColumn(RankingsStrings.SpotlightEndDate), + mapCountColumn = new InfoColumn(RankingsStrings.SpotlightMapCount), + participantsColumn = new InfoColumn(RankingsStrings.SpotlightParticipants) } }, new RankingsSortTabControl @@ -120,26 +125,26 @@ namespace osu.Game.Overlays.Rankings { startDateColumn.Value = dateToString(response.Spotlight.StartDate); endDateColumn.Value = dateToString(response.Spotlight.EndDate); - mapCountColumn.Value = response.BeatmapSets.Count.ToString(); - participantsColumn.Value = response.Spotlight.Participants?.ToString("N0"); + mapCountColumn.Value = response.BeatmapSets.Count.ToLocalisableString(@"N0"); + participantsColumn.Value = response.Spotlight.Participants?.ToLocalisableString(@"N0"); } - private string dateToString(DateTimeOffset date) => date.ToString("yyyy-MM-dd"); + private LocalisableString dateToString(DateTimeOffset date) => date.ToLocalisableString(@"yyyy-MM-dd"); private class InfoColumn : FillFlowContainer { - public string Value + public LocalisableString Value { set => valueText.Text = value; } private readonly OsuSpriteText valueText; - public InfoColumn(string name) + public InfoColumn(LocalisableString name) { AutoSizeAxes = Axes.Both; Direction = FillDirection.Vertical; - Margin = new MarginPadding { Vertical = 10 }; + Padding = new MarginPadding { Vertical = 15 }; Children = new Drawable[] { new OsuSpriteText @@ -150,11 +155,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 +179,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..a908380e95 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -1,15 +1,16 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using 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; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings.Tables { @@ -20,14 +21,14 @@ namespace osu.Game.Overlays.Rankings.Tables { } - protected override TableColumn[] CreateAdditionalHeaders() => new[] + protected override RankingsTableColumn[] CreateAdditionalHeaders() => new[] { - new TableColumn("Active Users", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Play Count", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Ranked Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Avg. Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Performance", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Avg. Perf.", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatActiveUsers, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatPlayCount, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatRankedScore, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatAverageScore, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatPerformance, Anchor.Centre, new Dimension(GridSizeMode.AutoSize), true), + new RankingsTableColumn(RankingsStrings.StatAveragePerformance, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), }; protected override Country GetCountry(CountryStatistics item) => item.Country; @@ -36,61 +37,46 @@ namespace osu.Game.Overlays.Rankings.Tables protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] { - new ColoredRowText + new ColouredRowText { - Text = $@"{item.ActiveUsers:N0}", + Text = item.ActiveUsers.ToLocalisableString(@"N0") }, - new ColoredRowText + new ColouredRowText { - Text = $@"{item.PlayCount:N0}", + Text = item.PlayCount.ToLocalisableString(@"N0") }, - new ColoredRowText + new ColouredRowText { - Text = $@"{item.RankedScore:N0}", + Text = item.RankedScore.ToLocalisableString(@"N0") }, - new ColoredRowText + new ColouredRowText { - Text = $@"{item.RankedScore / Math.Max(item.ActiveUsers, 1):N0}", + Text = (item.RankedScore / Math.Max(item.ActiveUsers, 1)).ToLocalisableString(@"N0") }, new RowText { - Text = $@"{item.Performance:N0}", + Text = item.Performance.ToLocalisableString(@"N0") }, - new ColoredRowText + new ColouredRowText { - Text = $@"{item.Performance / Math.Max(item.ActiveUsers, 1):N0}", + Text = (item.Performance / Math.Max(item.ActiveUsers, 1)).ToLocalisableString(@"N0") } }; - 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/PerformanceTable.cs b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs index 1e6b2307e0..17c17b1f1a 100644 --- a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Rankings.Tables @@ -15,14 +17,14 @@ namespace osu.Game.Overlays.Rankings.Tables { } - protected override TableColumn[] CreateUniqueHeaders() => new[] + protected override RankingsTableColumn[] CreateUniqueHeaders() => new[] { - new TableColumn("Performance", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatPerformance, Anchor.Centre, new Dimension(GridSizeMode.AutoSize), true), }; protected override Drawable[] CreateUniqueContent(UserStatistics item) => new Drawable[] { - new RowText { Text = $@"{item.PP:N0}", } + new RowText { Text = item.PP?.ToLocalisableString(@"N0"), } }; } } diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index 943897581e..6e6230f958 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; @@ -10,9 +10,11 @@ using osu.Framework.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Rankings.Tables { @@ -20,7 +22,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 +38,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,36 +50,38 @@ 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(); + Columns = mainHeaders.Concat(CreateAdditionalHeaders()).Cast().ToArray(); Content = rankings.Select((s, i) => createContent((page - 1) * items_per_page + i, s)).ToArray().ToRectangular(); } private Drawable[] createContent(int index, TModel item) => new Drawable[] { createIndexDrawable(index), createMainContent(item) }.Concat(CreateAdditionalContent(item)).ToArray(); - private static TableColumn[] mainHeaders => new[] + private static RankingsTableColumn[] 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 RankingsTableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.Absolute, 40)), // place + new RankingsTableColumn(string.Empty, Anchor.CentreLeft, new Dimension()), // flag and username (country name) }; - protected abstract TableColumn[] CreateAdditionalHeaders(); + protected abstract RankingsTableColumn[] CreateAdditionalHeaders(); protected abstract Drawable[] CreateAdditionalContent(TModel item); - protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty, HighlightedColumn()); + protected sealed override Drawable CreateHeader(int index, TableColumn column) + => (column as RankingsTableColumn)?.CreateHeaderText() ?? new HeaderText(column?.Header ?? default, false); 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}", + Text = (index + 1).ToLocalisableString(@"\##"), Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.SemiBold) }; @@ -84,37 +89,49 @@ 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 RankingsTableColumn : TableColumn { - private readonly string highlighted; + protected readonly bool Highlighted; - public HeaderText(string text, string highlighted) + public RankingsTableColumn(LocalisableString? header = null, Anchor anchor = Anchor.TopLeft, Dimension dimension = null, bool highlighted = false) + : base(header, anchor, dimension) { - this.highlighted = highlighted; + Highlighted = highlighted; + } + + public virtual HeaderText CreateHeaderText() => new HeaderText(Header, Highlighted); + } + + protected class HeaderText : OsuSpriteText + { + private readonly bool isHighlighted; + + public HeaderText(LocalisableString text, bool isHighlighted) + { + 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,11 +141,11 @@ 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 }; } } - protected class ColoredRowText : RowText + protected class ColouredRowText : RowText { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs index 370ee506c2..934da4501e 100644 --- a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Rankings.Tables @@ -15,24 +17,22 @@ namespace osu.Game.Overlays.Rankings.Tables { } - protected override TableColumn[] CreateUniqueHeaders() => new[] + protected override RankingsTableColumn[] CreateUniqueHeaders() => new[] { - new TableColumn("Total Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Ranked Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)) + new RankingsTableColumn(RankingsStrings.StatTotalScore, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatRankedScore, Anchor.Centre, new Dimension(GridSizeMode.AutoSize), true) }; protected override Drawable[] CreateUniqueContent(UserStatistics item) => new Drawable[] { - new ColoredRowText + new ColouredRowText { - Text = $@"{item.TotalScore:N0}", + Text = item.TotalScore.ToLocalisableString(@"N0"), }, new RowText { - Text = $@"{item.RankedScore:N0}", + Text = item.RankedScore.ToLocalisableString(@"N0") } }; - - protected override string HighlightedColumn() => @"Ranked Score"; } } diff --git a/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs 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..cc2ef55a2b 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -3,12 +3,15 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Users; using osu.Game.Scoring; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings.Tables { @@ -19,39 +22,67 @@ namespace osu.Game.Overlays.Rankings.Tables { } - protected override TableColumn[] CreateAdditionalHeaders() => new[] - { - new TableColumn("Accuracy", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Play Count", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - }.Concat(CreateUniqueHeaders()).Concat(new[] - { - new TableColumn("SS", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("S", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("A", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - }).ToArray(); + protected virtual IEnumerable GradeColumns => new List { RankingsStrings.Statss, RankingsStrings.Stats, RankingsStrings.Stata }; + + protected override RankingsTableColumn[] CreateAdditionalHeaders() => new[] + { + new RankingsTableColumn(RankingsStrings.StatAccuracy, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatPlayCount, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + }.Concat(CreateUniqueHeaders()) + .Concat(GradeColumns.Select(grade => new GradeTableColumn(grade, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)))) + .ToArray(); protected sealed override Country GetCountry(UserStatistics item) => item.User.Country; protected sealed override Drawable CreateFlagContent(UserStatistics item) { - var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { AutoSizeAxes = Axes.Both }; + 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; } protected sealed override Drawable[] CreateAdditionalContent(UserStatistics item) => new[] { - new ColoredRowText { Text = item.DisplayAccuracy, }, - new ColoredRowText { Text = $@"{item.PlayCount:N0}", }, + new ColouredRowText { Text = item.DisplayAccuracy, }, + new ColouredRowText { Text = item.PlayCount.ToLocalisableString(@"N0") }, }.Concat(CreateUniqueContent(item)).Concat(new[] { - new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.XH] + item.GradesCount[ScoreRank.X]:N0}", }, - new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.SH] + item.GradesCount[ScoreRank.S]:N0}", }, - new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.A]:N0}", } + new ColouredRowText { Text = (item.GradesCount[ScoreRank.XH] + item.GradesCount[ScoreRank.X]).ToLocalisableString(@"N0"), }, + new ColouredRowText { Text = (item.GradesCount[ScoreRank.SH] + item.GradesCount[ScoreRank.S]).ToLocalisableString(@"N0"), }, + new ColouredRowText { Text = item.GradesCount[ScoreRank.A].ToLocalisableString(@"N0"), } }).ToArray(); - protected abstract TableColumn[] CreateUniqueHeaders(); + protected abstract RankingsTableColumn[] CreateUniqueHeaders(); protected abstract Drawable[] CreateUniqueContent(UserStatistics item); + + private class GradeTableColumn : RankingsTableColumn + { + public GradeTableColumn(LocalisableString? header = null, Anchor anchor = Anchor.TopLeft, Dimension dimension = null, bool highlighted = false) + : base(header, anchor, dimension, highlighted) + { + } + + public override HeaderText CreateHeaderText() => new GradeHeaderText(Header, Highlighted); + } + + private class GradeHeaderText : HeaderText + { + public GradeHeaderText(LocalisableString text, bool isHighlighted) + : base(text, isHighlighted) + { + Margin = new MarginPadding + { + // Grade columns have extra horizontal padding for readibility + Horizontal = 20, + Vertical = 5 + }; + } + } } } diff --git a/osu.Game/Overlays/RankingsOverlay.cs b/osu.Game/Overlays/RankingsOverlay.cs index a093969115..80ce2e038d 100644 --- a/osu.Game/Overlays/RankingsOverlay.cs +++ b/osu.Game/Overlays/RankingsOverlay.cs @@ -23,7 +23,10 @@ namespace osu.Game.Overlays private IAPIProvider api { get; set; } [Resolved] - private Bindable ruleset { get; set; } + private IBindable parentRuleset { get; set; } + + [Cached] + private readonly Bindable ruleset = new Bindable(); public RankingsOverlay() : base(OverlayColourScheme.Green) @@ -54,6 +57,19 @@ namespace osu.Game.Overlays }); } + private bool requiresRulesetUpdate = true; + + protected override void PopIn() + { + if (requiresRulesetUpdate) + { + ruleset.Value = parentRuleset.Value; + requiresRulesetUpdate = false; + } + + base.PopIn(); + } + protected override void OnTabChanged(RankingsScope tab) { // country filtering is only valid for performance scope. @@ -127,19 +143,27 @@ namespace osu.Game.Overlays switch (request) { case GetUserRankingsRequest userRequest: + if (userRequest.Response == null) + return null; + switch (userRequest.Type) { case UserRankingsType.Performance: - return new PerformanceTable(1, userRequest.Result.Users); + return new PerformanceTable(1, userRequest.Response.Users); case UserRankingsType.Score: - return new ScoresTable(1, userRequest.Result.Users); + return new ScoresTable(1, userRequest.Response.Users); } return null; case GetCountryRankingsRequest countryRequest: - return new CountriesTable(1, countryRequest.Result.Countries); + { + if (countryRequest.Response == null) + return null; + + return new CountriesTable(1, countryRequest.Response.Countries); + } } return null; diff --git a/osu.Game/Overlays/RestoreDefaultValueButton.cs b/osu.Game/Overlays/RestoreDefaultValueButton.cs index fe36f6ba6d..87a294cc10 100644 --- a/osu.Game/Overlays/RestoreDefaultValueButton.cs +++ b/osu.Game/Overlays/RestoreDefaultValueButton.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.UserInterface; @@ -20,18 +21,29 @@ 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(); - private Color4 buttonColour; + current.ValueChanged += _ => UpdateState(); + current.DefaultChanged += _ => UpdateState(); + current.DisabledChanged += _ => UpdateState(); + + if (IsLoaded) + UpdateState(); + } + } private bool hovering; @@ -47,12 +59,11 @@ namespace osu.Game.Overlays private void load(OsuColour colour) { BackgroundColour = colour.Yellow; - buttonColour = colour.Yellow; Content.Width = 0.33f; Content.CornerRadius = 3; Content.EdgeEffect = new EdgeEffectParameters { - Colour = buttonColour.Opacity(0.1f), + Colour = BackgroundColour.Opacity(0.1f), Type = EdgeEffectType.Glow, Radius = 2, }; @@ -62,7 +73,8 @@ namespace osu.Game.Overlays Action += () => { - if (!current.Disabled) current.SetDefault(); + if (!current.Disabled) + current.SetDefault(); }; } @@ -70,11 +82,9 @@ namespace osu.Game.Overlays { base.LoadComplete(); - Current.ValueChanged += _ => UpdateState(); - Current.DisabledChanged += _ => UpdateState(); - Current.DefaultChanged += _ => UpdateState(); - - UpdateState(); + // avoid unnecessary transforms on first display. + Alpha = currentAlpha; + Background.Colour = currentColour; } public LocalisableString TooltipText => "revert to default"; @@ -94,14 +104,16 @@ namespace osu.Game.Overlays public void UpdateState() => Scheduler.AddOnce(updateState); + private float currentAlpha => current.IsDefault ? 0f : hovering && !current.Disabled ? 1f : 0.65f; + private ColourInfo currentColour => current.Disabled ? Color4.Gray : BackgroundColour; + private void updateState() { if (current == null) return; - this.FadeTo(current.IsDefault ? 0f : - hovering && !current.Disabled ? 1f : 0.65f, 200, Easing.OutQuint); - this.FadeColour(current.Disabled ? Color4.Gray : buttonColour, 200, Easing.OutQuint); + this.FadeTo(currentAlpha, 200, Easing.OutQuint); + Background.FadeColour(currentColour, 200, Easing.OutQuint); } } } diff --git a/osu.Game/Overlays/Settings/DangerousSettingsButton.cs b/osu.Game/Overlays/Settings/DangerousSettingsButton.cs index c02db40eca..4ca3ace8a1 100644 --- a/osu.Game/Overlays/Settings/DangerousSettingsButton.cs +++ b/osu.Game/Overlays/Settings/DangerousSettingsButton.cs @@ -14,10 +14,7 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader] private void load(OsuColour colours) { - BackgroundColour = colours.Pink; - - Triangles.ColourDark = colours.PinkDark; - Triangles.ColourLight = colours.PinkLight; + BackgroundColour = colours.Pink3; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index b31e7dc45b..0c54ae2763 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -8,29 +8,40 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio { public class AudioDevicesSettings : SettingsSubsection { - protected override string Header => "Devices"; + protected override LocalisableString Header => AudioSettingsStrings.AudioDevicesHeader; [Resolved] private AudioManager audio { get; set; } private SettingsDropdown dropdown; - protected override void Dispose(bool isDisposing) + [BackgroundDependencyLoader] + private void load() { - base.Dispose(isDisposing); - - if (audio != null) + Children = new Drawable[] { - audio.OnNewDevice -= onDeviceChanged; - audio.OnLostDevice -= onDeviceChanged; - } + dropdown = new AudioDeviceSettingsDropdown + { + LabelText = AudioSettingsStrings.OutputDevice, + Keywords = new[] { "speaker", "headphone", "output" } + } + }; + + updateItems(); + + audio.OnNewDevice += onDeviceChanged; + audio.OnLostDevice += onDeviceChanged; + dropdown.Current = audio.AudioDevice; } + private void onDeviceChanged(string name) => updateItems(); + private void updateItems() { var deviceItems = new List { string.Empty }; @@ -49,26 +60,15 @@ namespace osu.Game.Overlays.Settings.Sections.Audio dropdown.Items = deviceItems.Distinct().ToList(); } - private void onDeviceChanged(string name) => updateItems(); - - protected override void LoadComplete() + protected override void Dispose(bool isDisposing) { - base.LoadComplete(); + base.Dispose(isDisposing); - Children = new Drawable[] + if (audio != null) { - dropdown = new AudioDeviceSettingsDropdown - { - Keywords = new[] { "speaker", "headphone", "output" } - } - }; - - updateItems(); - - dropdown.Current = audio.AudioDevice; - - audio.OnNewDevice += onDeviceChanged; - audio.OnLostDevice += onDeviceChanged; + audio.OnNewDevice -= onDeviceChanged; + audio.OnLostDevice -= onDeviceChanged; + } } private class AudioDeviceSettingsDropdown : SettingsDropdown @@ -78,7 +78,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio private class AudioDeviceDropdownControl : DropdownControl { protected override LocalisableString GenerateItemText(string item) - => string.IsNullOrEmpty(item) ? "Default" : base.GenerateItemText(item); + => string.IsNullOrEmpty(item) ? CommonStrings.Default : base.GenerateItemText(item); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index 1ae297f2a9..9345d3fcc7 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -1,17 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio { public class OffsetSettings : SettingsSubsection { - protected override string Header => "Offset Adjustment"; + protected override LocalisableString Header => AudioSettingsStrings.OffsetHeader; + + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new[] { "universal", "uo", "timing" }); [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -20,13 +25,13 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { new SettingsSlider { - LabelText = "Audio offset", + LabelText = AudioSettingsStrings.AudioOffset, Current = config.GetBindable(OsuSetting.AudioOffset), KeyboardStep = 1f }, new SettingsButton { - Text = "Offset wizard" + Text = AudioSettingsStrings.OffsetWizard } }; } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index c172a76ab9..00c1cb8f43 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -4,13 +4,15 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio { public class VolumeSettings : SettingsSubsection { - protected override string Header => "Volume"; + protected override LocalisableString Header => AudioSettingsStrings.VolumeHeader; [BackgroundDependencyLoader] private void load(AudioManager audio, OsuConfigManager config) @@ -19,28 +21,28 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { new SettingsSlider { - LabelText = "Master", + LabelText = AudioSettingsStrings.MasterVolume, Current = audio.Volume, KeyboardStep = 0.01f, DisplayAsPercentage = true }, new SettingsSlider { - LabelText = "Master (window inactive)", + LabelText = AudioSettingsStrings.MasterVolumeInactive, Current = config.GetBindable(OsuSetting.VolumeInactive), KeyboardStep = 0.01f, DisplayAsPercentage = true }, new SettingsSlider { - LabelText = "Effect", + LabelText = AudioSettingsStrings.EffectVolume, Current = audio.VolumeSample, KeyboardStep = 0.01f, DisplayAsPercentage = true }, new SettingsSlider { - LabelText = "Music", + LabelText = AudioSettingsStrings.MusicVolume, Current = audio.VolumeTrack, KeyboardStep = 0.01f, DisplayAsPercentage = true diff --git a/osu.Game/Overlays/Settings/Sections/AudioSection.cs b/osu.Game/Overlays/Settings/Sections/AudioSection.cs index 7072d8e63d..694da0529a 100644 --- a/osu.Game/Overlays/Settings/Sections/AudioSection.cs +++ b/osu.Game/Overlays/Settings/Sections/AudioSection.cs @@ -3,15 +3,17 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Localisation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Audio; namespace osu.Game.Overlays.Settings.Sections { public class AudioSection : SettingsSection { - public override string Header => "Audio"; + public override LocalisableString Header => AudioSettingsStrings.AudioSectionHeader; public override Drawable CreateIcon() => new SpriteIcon { diff --git a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs index 4a9c9bd8a2..25e20911b8 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs @@ -4,14 +4,16 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Screens; +using osu.Game.Localisation; using osu.Game.Screens.Import; namespace osu.Game.Overlays.Settings.Sections.Debug { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => DebugSettingsStrings.GeneralHeader; [BackgroundDependencyLoader(true)] private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig, OsuGame game) @@ -20,18 +22,18 @@ namespace osu.Game.Overlays.Settings.Sections.Debug { new SettingsCheckbox { - LabelText = "Show log overlay", + LabelText = DebugSettingsStrings.ShowLogOverlay, Current = frameworkConfig.GetBindable(FrameworkSetting.ShowLogOverlay) }, new SettingsCheckbox { - LabelText = "Bypass front-to-back render pass", + LabelText = DebugSettingsStrings.BypassFrontToBackPass, Current = config.GetBindable(DebugSetting.BypassFrontToBackPass) } }; Add(new SettingsButton { - Text = "Import files", + Text = DebugSettingsStrings.ImportFiles, Action = () => game?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) }); } diff --git a/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs index db64c9a8ac..07fb0aca5a 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs @@ -4,13 +4,15 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Debug { public class MemorySettings : SettingsSubsection { - protected override string Header => "Memory"; + protected override LocalisableString Header => DebugSettingsStrings.MemoryHeader; [BackgroundDependencyLoader] private void load(FrameworkDebugConfigManager config, GameHost host) @@ -19,7 +21,7 @@ namespace osu.Game.Overlays.Settings.Sections.Debug { new SettingsButton { - Text = "Clear all caches", + Text = DebugSettingsStrings.ClearAllCaches, Action = host.Collect }, }; diff --git a/osu.Game/Overlays/Settings/Sections/DebugSection.cs b/osu.Game/Overlays/Settings/Sections/DebugSection.cs index 44d4088972..aa85ec920c 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSection.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSection.cs @@ -3,13 +3,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Debug; namespace osu.Game.Overlays.Settings.Sections { public class DebugSection : SettingsSection { - public override string Header => "Debug"; + public override LocalisableString Header => DebugSettingsStrings.DebugSectionHeader; public override Drawable CreateIcon() => new SpriteIcon { diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs new file mode 100644 index 0000000000..dba64d695a --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.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.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class AudioSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.AudioHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.PositionalHitsounds, + Current = config.GetBindable(OsuSetting.PositionalHitSounds) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak, + Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs new file mode 100644 index 0000000000..94e0c5e494 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class BackgroundSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.BackgroundHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsSlider + { + LabelText = GameplaySettingsStrings.BackgroundDim, + Current = config.GetBindable(OsuSetting.DimLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider + { + LabelText = GameplaySettingsStrings.BackgroundBlur, + Current = config.GetBindable(OsuSetting.BlurLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.LightenDuringBreaks, + Current = config.GetBindable(OsuSetting.LightenDuringBreaks) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.FadePlayfieldWhenHealthLow, + Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow), + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs new file mode 100644 index 0000000000..aaa60ce81b --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class BeatmapSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.BeatmapHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapSkins, + Current = config.GetBindable(OsuSetting.BeatmapSkins) + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapColours, + Current = config.GetBindable(OsuSetting.BeatmapColours) + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapHitsounds, + Current = config.GetBindable(OsuSetting.BeatmapHitsounds) + }, + new SettingsCheckbox + { + LabelText = GraphicsSettingsStrings.StoryboardVideo, + Current = config.GetBindable(OsuSetting.ShowStoryboard) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 0b5ec4f338..d4e4fd571d 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -1,94 +1,36 @@ -// 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; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Rulesets.Scoring; namespace osu.Game.Overlays.Settings.Sections.Gameplay { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => GameplaySettingsStrings.GeneralHeader; [BackgroundDependencyLoader] private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsSlider - { - LabelText = "Background dim", - Current = config.GetBindable(OsuSetting.DimLevel), - KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsSlider - { - LabelText = "Background blur", - Current = config.GetBindable(OsuSetting.BlurLevel), - KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsCheckbox - { - LabelText = "Lighten playfield during breaks", - Current = config.GetBindable(OsuSetting.LightenDuringBreaks) - }, - new SettingsEnumDropdown - { - LabelText = "HUD overlay visibility mode", - Current = config.GetBindable(OsuSetting.HUDVisibilityMode) - }, - new SettingsCheckbox - { - LabelText = "Show difficulty graph on progress bar", - Current = config.GetBindable(OsuSetting.ShowProgressGraph) - }, - new SettingsCheckbox - { - LabelText = "Show health display even when you can't fail", - Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), - Keywords = new[] { "hp", "bar" } - }, - new SettingsCheckbox - { - LabelText = "Fade playfield to red when health is low", - Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow), - }, - new SettingsCheckbox - { - LabelText = "Always show key overlay", - Current = config.GetBindable(OsuSetting.KeyOverlay) - }, - new SettingsCheckbox - { - LabelText = "Positional hitsounds", - Current = config.GetBindable(OsuSetting.PositionalHitSounds) - }, - new SettingsCheckbox - { - LabelText = "Always play first combo break sound", - Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) - }, new SettingsEnumDropdown { - LabelText = "Score display mode", + LabelText = GameplaySettingsStrings.ScoreDisplayMode, Current = config.GetBindable(OsuSetting.ScoreDisplayMode), Keywords = new[] { "scoring" } }, - }; - - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) - { - Add(new SettingsCheckbox + new SettingsCheckbox { - LabelText = "Disable Windows key during gameplay", - Current = config.GetBindable(OsuSetting.GameplayDisableWinKey) - }); - } + LabelText = GraphicsSettingsStrings.HitLighting, + Current = config.GetBindable(OsuSetting.HitLighting) + }, + }; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs new file mode 100644 index 0000000000..e1b452e322 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class HUDSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.HUDHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsEnumDropdown + { + LabelText = GameplaySettingsStrings.HUDVisibilityMode, + Current = config.GetBindable(OsuSetting.HUDVisibilityMode) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.ShowDifficultyGraph, + Current = config.GetBindable(OsuSetting.ShowProgressGraph) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, + Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), + Keywords = new[] { "hp", "bar" } + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysShowKeyOverlay, + Current = config.GetBindable(OsuSetting.KeyOverlay) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs new file mode 100644 index 0000000000..962572ca6e --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class InputSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.InputHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsSlider + { + LabelText = SkinSettingsStrings.GameplayCursorSize, + Current = config.GetBindable(OsuSetting.GameplayCursorSize), + KeyboardStep = 0.01f + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.AutoCursorSize, + Current = config.GetBindable(OsuSetting.AutoCursorSize) + }, + }; + + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + { + Add(new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.DisableWinKey, + Current = config.GetBindable(OsuSetting.GameplayDisableWinKey) + }); + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs index 2b2fb9cef7..dfa060e8d5 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs @@ -4,13 +4,15 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay { public class ModsSettings : SettingsSubsection { - protected override string Header => "Mods"; + protected override LocalisableString Header => GameplaySettingsStrings.ModsHeader; public override IEnumerable FilterTerms => base.FilterTerms.Concat(new[] { "mod" }); @@ -21,7 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { new SettingsCheckbox { - LabelText = "Increase visibility of first object when visual impairment mods are enabled", + LabelText = GameplaySettingsStrings.IncreaseFirstObjectVisibility, Current = config.GetBindable(OsuSetting.IncreaseFirstObjectVisibility), }, }; diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index acb94a6a01..120e2d908c 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -1,24 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Overlays.Settings.Sections.Gameplay; -using osu.Game.Rulesets; -using System.Linq; using osu.Framework.Graphics.Sprites; -using osu.Framework.Logging; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings.Sections.Gameplay; namespace osu.Game.Overlays.Settings.Sections { public class GameplaySection : SettingsSection { - public override string Header => "Gameplay"; + public override LocalisableString Header => GameplaySettingsStrings.GameplaySectionHeader; public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Regular.Circle + Icon = FontAwesome.Regular.DotCircle }; public GameplaySection() @@ -26,27 +23,13 @@ namespace osu.Game.Overlays.Settings.Sections Children = new Drawable[] { new GeneralSettings(), + new AudioSettings(), + new BeatmapSettings(), + new BackgroundSettings(), + new HUDSettings(), + new InputSettings(), new ModsSettings(), }; } - - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance())) - { - try - { - SettingsSubsection section = ruleset.CreateSettings(); - - if (section != null) - Add(section); - } - catch (Exception e) - { - Logger.Error(e, "Failed to load ruleset settings"); - } - } - } } } diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index dfcdb8e340..200618c469 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -5,6 +5,7 @@ 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; @@ -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 => GeneralSettingsStrings.LanguageHeader; [BackgroundDependencyLoader] private void load(FrameworkConfigManager frameworkConfig) @@ -26,11 +27,11 @@ namespace osu.Game.Overlays.Settings.Sections.General { languageSelection = new SettingsEnumDropdown { - LabelText = "Language", + LabelText = GeneralSettingsStrings.LanguageDropdown, }, new SettingsCheckbox { - LabelText = "Prefer metadata in original language", + LabelText = GeneralSettingsStrings.PreferOriginalMetadataLanguage, Current = frameworkConfig.GetBindable(FrameworkSetting.ShowUnicode) }, }; diff --git a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs deleted file mode 100644 index 8f757f7a36..0000000000 --- a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs +++ /dev/null @@ -1,421 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Configuration; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osuTK; -using osu.Game.Users; -using System.ComponentModel; -using osu.Framework.Bindables; -using osu.Game.Graphics; -using osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Graphics.Containers; -using RectangleF = osu.Framework.Graphics.Primitives.RectangleF; -using Container = osu.Framework.Graphics.Containers.Container; - -namespace osu.Game.Overlays.Settings.Sections.General -{ - public class LoginSettings : FillFlowContainer - { - private bool bounding = true; - private LoginForm form; - - [Resolved] - private OsuColour colours { get; set; } - - private UserGridPanel panel; - private UserDropdown dropdown; - - /// - /// Called to request a hide of a parent displaying this container. - /// - public Action RequestHide; - - private readonly IBindable apiState = new Bindable(); - - [Resolved] - private IAPIProvider api { get; set; } - - public override RectangleF BoundingBox => bounding ? base.BoundingBox : RectangleF.Empty; - - public bool Bounding - { - get => bounding; - set - { - bounding = value; - Invalidate(Invalidation.MiscGeometry); - } - } - - public LoginSettings() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Direction = FillDirection.Vertical; - Spacing = new Vector2(0f, 5f); - } - - [BackgroundDependencyLoader] - private void load() - { - apiState.BindTo(api.State); - apiState.BindValueChanged(onlineStateChanged, true); - } - - private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => - { - form = null; - - switch (state.NewValue) - { - case APIState.Offline: - Children = new Drawable[] - { - new OsuSpriteText - { - Text = "ACCOUNT", - Margin = new MarginPadding { Bottom = 5 }, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - }, - form = new LoginForm - { - RequestHide = RequestHide - } - }; - break; - - case APIState.Failing: - case APIState.Connecting: - LinkFlowContainer linkFlow; - - Children = new Drawable[] - { - new LoadingSpinner - { - State = { Value = Visibility.Visible }, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - linkFlow = new LinkFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - TextAnchor = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Text = state.NewValue == APIState.Failing ? "Connection is failing, will attempt to reconnect... " : "Attempting to connect... ", - Margin = new MarginPadding { Top = 10, Bottom = 10 }, - }, - }; - - linkFlow.AddLink("cancel", api.Logout, string.Empty); - break; - - case APIState.Online: - Children = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 20, Right = 20 }, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0f, 10f), - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new[] - { - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "Signed in", - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold), - Margin = new MarginPadding { Top = 5, Bottom = 5 }, - }, - }, - }, - panel = new UserGridPanel(api.LocalUser.Value) - { - RelativeSizeAxes = Axes.X, - Action = RequestHide - }, - dropdown = new UserDropdown { RelativeSizeAxes = Axes.X }, - }, - }, - }; - - panel.Status.BindTo(api.LocalUser.Value.Status); - panel.Activity.BindTo(api.LocalUser.Value.Activity); - - dropdown.Current.BindValueChanged(action => - { - switch (action.NewValue) - { - case UserAction.Online: - api.LocalUser.Value.Status.Value = new UserStatusOnline(); - dropdown.StatusColour = colours.Green; - break; - - case UserAction.DoNotDisturb: - api.LocalUser.Value.Status.Value = new UserStatusDoNotDisturb(); - dropdown.StatusColour = colours.Red; - break; - - case UserAction.AppearOffline: - api.LocalUser.Value.Status.Value = new UserStatusOffline(); - dropdown.StatusColour = colours.Gray7; - break; - - case UserAction.SignOut: - api.Logout(); - break; - } - }, true); - break; - } - - if (form != null) GetContainingInputManager()?.ChangeFocus(form); - }); - - public override bool AcceptsFocus => true; - - protected override bool OnClick(ClickEvent e) => true; - - protected override void OnFocus(FocusEvent e) - { - if (form != null) GetContainingInputManager().ChangeFocus(form); - base.OnFocus(e); - } - - private class LoginForm : FillFlowContainer - { - private TextBox username; - private TextBox password; - private ShakeContainer shakeSignIn; - - [Resolved(CanBeNull = true)] - private IAPIProvider api { get; set; } - - public Action RequestHide; - - private void performLogin() - { - if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) - api?.Login(username.Text, password.Text); - else - shakeSignIn.Shake(); - } - - [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuConfigManager config, AccountCreationOverlay accountCreation) - { - Direction = FillDirection.Vertical; - Spacing = new Vector2(0, 5); - AutoSizeAxes = Axes.Y; - RelativeSizeAxes = Axes.X; - Children = new Drawable[] - { - username = new OsuTextBox - { - PlaceholderText = "username", - RelativeSizeAxes = Axes.X, - Text = api?.ProvidedUsername ?? string.Empty, - TabbableContentContainer = this - }, - password = new OsuPasswordTextBox - { - PlaceholderText = "password", - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - }, - new SettingsCheckbox - { - LabelText = "Remember username", - Current = config.GetBindable(OsuSetting.SaveUsername), - }, - new SettingsCheckbox - { - LabelText = "Stay signed in", - Current = config.GetBindable(OsuSetting.SavePassword), - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - shakeSignIn = new ShakeContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new SettingsButton - { - Text = "Sign in", - Action = performLogin - }, - } - } - }, - new SettingsButton - { - Text = "Register", - Action = () => - { - RequestHide(); - accountCreation.Show(); - } - } - }; - - password.OnCommit += (sender, newText) => performLogin(); - } - - public override bool AcceptsFocus => true; - - protected override bool OnClick(ClickEvent e) => true; - - protected override void OnFocus(FocusEvent e) - { - Schedule(() => { GetContainingInputManager().ChangeFocus(string.IsNullOrEmpty(username.Text) ? username : password); }); - } - } - - private class UserDropdown : OsuEnumDropdown - { - protected override DropdownHeader CreateHeader() => new UserDropdownHeader(); - - protected override DropdownMenu CreateMenu() => new UserDropdownMenu(); - - public Color4 StatusColour - { - set - { - if (Header is UserDropdownHeader h) - h.StatusColour = value; - } - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Gray5; - } - - private class UserDropdownMenu : OsuDropdownMenu - { - public UserDropdownMenu() - { - Masking = true; - CornerRadius = 5; - - Margin = new MarginPadding { Bottom = 5 }; - - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.25f), - Radius = 4, - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BackgroundColour = colours.Gray3; - } - - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableUserDropdownMenuItem(item); - - private class DrawableUserDropdownMenuItem : DrawableOsuDropdownMenuItem - { - public DrawableUserDropdownMenuItem(MenuItem item) - : base(item) - { - Foreground.Padding = new MarginPadding { Top = 5, Bottom = 5, Left = 10, Right = 5 }; - CornerRadius = 5; - } - - protected override Drawable CreateContent() => new Content - { - Label = { Margin = new MarginPadding { Left = UserDropdownHeader.LABEL_LEFT_MARGIN - 11 } } - }; - } - } - - private class UserDropdownHeader : OsuDropdownHeader - { - public const float LABEL_LEFT_MARGIN = 20; - - private readonly SpriteIcon statusIcon; - - public Color4 StatusColour - { - set => statusIcon.FadeColour(value, 500, Easing.OutQuint); - } - - public UserDropdownHeader() - { - Foreground.Padding = new MarginPadding { Left = 10, Right = 10 }; - Margin = new MarginPadding { Bottom = 5 }; - Masking = true; - CornerRadius = 5; - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.25f), - Radius = 4, - }; - - Icon.Size = new Vector2(14); - Icon.Margin = new MarginPadding(0); - - Foreground.Add(statusIcon = new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Regular.Circle, - Size = new Vector2(14), - }); - - Text.Margin = new MarginPadding { Left = LABEL_LEFT_MARGIN }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BackgroundColour = colours.Gray3; - } - } - } - - private enum UserAction - { - Online, - - [Description(@"Do not disturb")] - DoNotDisturb, - - [Description(@"Appear offline")] - AppearOffline, - - [Description(@"Sign out")] - SignOut, - } - } -} diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index c213313559..aa37748653 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -5,9 +5,11 @@ 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; +using osu.Game.Localisation; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Updater; @@ -19,7 +21,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 => GeneralSettingsStrings.UpdateHeader; private SettingsButton checkForUpdatesButton; @@ -31,7 +33,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { Add(new SettingsEnumDropdown { - LabelText = "Release stream", + LabelText = GeneralSettingsStrings.ReleaseStream, Current = config.GetBindable(OsuSetting.ReleaseStream), }); @@ -39,7 +41,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { Add(checkForUpdatesButton = new SettingsButton { - Text = "Check for updates", + Text = GeneralSettingsStrings.CheckUpdate, Action = () => { checkForUpdatesButton.Enabled.Value = false; @@ -64,13 +66,13 @@ namespace osu.Game.Overlays.Settings.Sections.General { Add(new SettingsButton { - Text = "Open osu! folder", + Text = GeneralSettingsStrings.OpenOsuFolder, Action = storage.OpenInNativeExplorer, }); Add(new SettingsButton { - Text = "Change folder location...", + Text = GeneralSettingsStrings.ChangeFolderLocation, Action = () => game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())) }); } diff --git a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs index fefc3fe6a7..87e9f34833 100644 --- a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs @@ -3,13 +3,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.General; namespace osu.Game.Overlays.Settings.Sections { public class GeneralSection : SettingsSection { - public override string Header => "General"; + public override LocalisableString Header => GeneralSettingsStrings.GeneralSectionHeader; public override Drawable CreateIcon() => new SpriteIcon { diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 669753d2cb..adf1453d1a 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -16,13 +16,14 @@ using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osuTK.Graphics; namespace osu.Game.Overlays.Settings.Sections.Graphics { public class LayoutSettings : SettingsSubsection { - protected override string Header => "Layout"; + protected override LocalisableString Header => GraphicsSettingsStrings.LayoutHeader; private FillFlowContainer> scalingSettings; @@ -67,20 +68,20 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { windowModeDropdown = new SettingsDropdown { - LabelText = "Screen mode", + LabelText = GraphicsSettingsStrings.ScreenMode, ItemSource = windowModes, Current = config.GetBindable(FrameworkSetting.WindowMode), }, resolutionDropdown = new ResolutionSettingsDropdown { - LabelText = "Resolution", + LabelText = GraphicsSettingsStrings.Resolution, ShowsDefaultIndicator = false, ItemSource = resolutions, Current = sizeFullscreen }, new SettingsSlider { - LabelText = "UI Scaling", + LabelText = GraphicsSettingsStrings.UIScaling, TransferValueOnCommit = true, Current = osuConfig.GetBindable(OsuSetting.UIScale), KeyboardStep = 0.01f, @@ -88,7 +89,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics }, new SettingsEnumDropdown { - LabelText = "Screen Scaling", + LabelText = GraphicsSettingsStrings.ScreenScaling, Current = osuConfig.GetBindable(OsuSetting.Scaling), Keywords = new[] { "scale", "letterbox" }, }, @@ -97,35 +98,33 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - AutoSizeDuration = transition_duration, - AutoSizeEasing = Easing.OutQuint, Masking = true, Children = new[] { new SettingsSlider { - LabelText = "Horizontal position", + LabelText = GraphicsSettingsStrings.HorizontalPosition, Current = scalingPositionX, KeyboardStep = 0.01f, DisplayAsPercentage = true }, new SettingsSlider { - LabelText = "Vertical position", + LabelText = GraphicsSettingsStrings.VerticalPosition, Current = scalingPositionY, KeyboardStep = 0.01f, DisplayAsPercentage = true }, new SettingsSlider { - LabelText = "Horizontal scale", + LabelText = GraphicsSettingsStrings.HorizontalScale, Current = scalingSizeX, KeyboardStep = 0.01f, DisplayAsPercentage = true }, new SettingsSlider { - LabelText = "Vertical scale", + LabelText = GraphicsSettingsStrings.VerticalScale, Current = scalingSizeY, KeyboardStep = 0.01f, DisplayAsPercentage = true @@ -145,9 +144,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { updateResolutionDropdown(); - const string not_fullscreen_note = "Running without fullscreen mode will increase your input latency!"; - - windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? not_fullscreen_note : string.Empty; + windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? GraphicsSettingsStrings.NotFullscreenNote : default; }, true); windowModes.BindCollectionChanged((sender, args) => @@ -177,13 +174,14 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingMode.BindValueChanged(mode => { scalingSettings.ClearTransforms(); - scalingSettings.AutoSizeAxes = mode.NewValue != ScalingMode.Off ? Axes.Y : Axes.None; + scalingSettings.AutoSizeDuration = transition_duration; + scalingSettings.AutoSizeEasing = Easing.OutQuint; - if (mode.NewValue == ScalingMode.Off) - scalingSettings.ResizeHeightTo(0, transition_duration, Easing.OutQuint); + updateScalingModeVisibility(); + }); - scalingSettings.ForEach(s => s.TransferValueOnCommit = mode.NewValue == ScalingMode.Everything); - }, true); + // initial update bypasses transforms + updateScalingModeVisibility(); void updateResolutionDropdown() { @@ -192,6 +190,15 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics else resolutionDropdown.Hide(); } + + void updateScalingModeVisibility() + { + if (scalingMode.Value == ScalingMode.Off) + scalingSettings.ResizeHeightTo(0, transition_duration, Easing.OutQuint); + + scalingSettings.AutoSizeAxes = scalingMode.Value != ScalingMode.Off ? Axes.Y : Axes.None; + scalingSettings.ForEach(s => s.TransferValueOnCommit = scalingMode.Value == ScalingMode.Everything); + } } private void bindPreviewEvent(Bindable bindable) @@ -245,7 +252,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics protected override LocalisableString GenerateItemText(Size item) { if (item == new Size(9999, 9999)) - return "Default"; + return CommonStrings.Default; return $"{item.Width}x{item.Height}"; } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 70225ff6b8..653f30a018 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -4,14 +4,16 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Graphics { public class RendererSettings : SettingsSubsection { - protected override string Header => "Renderer"; + protected override LocalisableString Header => GraphicsSettingsStrings.RendererHeader; private SettingsEnumDropdown frameLimiterDropdown; @@ -24,17 +26,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics // TODO: this needs to be a custom dropdown at some point frameLimiterDropdown = new SettingsEnumDropdown { - LabelText = "Frame limiter", + LabelText = GraphicsSettingsStrings.FrameLimiter, Current = config.GetBindable(FrameworkSetting.FrameSync) }, new SettingsEnumDropdown { - LabelText = "Threading mode", + LabelText = GraphicsSettingsStrings.ThreadingMode, Current = config.GetBindable(FrameworkSetting.ExecutionMode) }, new SettingsCheckbox { - LabelText = "Show FPS", + LabelText = GraphicsSettingsStrings.ShowFPS, Current = osuConfig.GetBindable(OsuSetting.ShowFpsDisplay) }, }; @@ -46,9 +48,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics frameLimiterDropdown.Current.BindValueChanged(limit => { - const string unlimited_frames_note = "Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. \"2x refresh rate\" is recommended."; - - frameLimiterDropdown.WarningText = limit.NewValue == FrameSync.Unlimited ? unlimited_frames_note : string.Empty; + frameLimiterDropdown.WarningText = limit.NewValue == FrameSync.Unlimited ? GraphicsSettingsStrings.UnlimitedFramesNote : default; }, true); } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs similarity index 56% rename from osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs rename to osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs index 30caa45995..dbb9ddc1c1 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs @@ -3,37 +3,29 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Graphics { - public class DetailSettings : SettingsSubsection + public class ScreenshotSettings : SettingsSubsection { - protected override string Header => "Detail Settings"; + protected override LocalisableString Header => GraphicsSettingsStrings.Screenshots; [BackgroundDependencyLoader] private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsCheckbox - { - LabelText = "Storyboard / Video", - Current = config.GetBindable(OsuSetting.ShowStoryboard) - }, - new SettingsCheckbox - { - LabelText = "Hit Lighting", - Current = config.GetBindable(OsuSetting.HitLighting) - }, new SettingsEnumDropdown { - LabelText = "Screenshot format", + LabelText = GraphicsSettingsStrings.ScreenshotFormat, Current = config.GetBindable(OsuSetting.ScreenshotFormat) }, new SettingsCheckbox { - LabelText = "Show menu cursor in screenshots", + LabelText = GraphicsSettingsStrings.ShowCursorInScreenshots, Current = config.GetBindable(OsuSetting.ScreenshotCaptureMenuCursor) } }; diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index 4ade48031f..591848506a 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -3,13 +3,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Graphics; namespace osu.Game.Overlays.Settings.Sections { public class GraphicsSection : SettingsSection { - public override string Header => "Graphics"; + public override LocalisableString Header => GraphicsSettingsStrings.GraphicsSectionHeader; public override Drawable CreateIcon() => new SpriteIcon { @@ -20,9 +22,9 @@ namespace osu.Game.Overlays.Settings.Sections { Children = new Drawable[] { - new RendererSettings(), new LayoutSettings(), - new DetailSettings(), + new RendererSettings(), + new ScreenshotSettings(), }; } } 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/KeyBinding/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs similarity index 77% rename from osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs rename to osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs index c905397e77..3350ff4eaa 100644 --- a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs @@ -3,10 +3,11 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Input.Bindings; -using osu.Game.Overlays.Settings; +using osu.Game.Localisation; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public class GlobalKeyBindingsSection : SettingsSection { @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.KeyBinding Icon = FontAwesome.Solid.Globe }; - public override string Header => "Global"; + public override LocalisableString Header => InputSettingsStrings.GlobalKeyBindingHeader; public GlobalKeyBindingsSection(GlobalActionContainer manager) { @@ -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 => InputSettingsStrings.SongSelectSection; 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 => InputSettingsStrings.InGameSection; 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 => InputSettingsStrings.AudioSection; 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 => InputSettingsStrings.EditorSection; public EditorKeyBindingsSubsection(GlobalActionContainer manager) : base(null) diff --git a/osu.Game/Overlays/KeyBindingPanel.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs similarity index 80% rename from osu.Game/Overlays/KeyBindingPanel.cs rename to osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs index 928bd080fa..67f1bb8d3e 100644 --- a/osu.Game/Overlays/KeyBindingPanel.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs @@ -4,15 +4,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Input.Bindings; -using osu.Game.Overlays.KeyBinding; -using osu.Game.Overlays.Settings; +using osu.Game.Localisation; using osu.Game.Rulesets; -namespace osu.Game.Overlays +namespace osu.Game.Overlays.Settings.Sections.Input { public class KeyBindingPanel : SettingsSubPanel { - protected override Drawable CreateHeader() => new SettingsHeader("key configuration", "Customise your keys!"); + protected override Drawable CreateHeader() => new SettingsHeader(InputSettingsStrings.KeyBindingPanelHeader, InputSettingsStrings.KeyBindingPanelDescription); [BackgroundDependencyLoader(permitNulls: true)] private void load(RulesetStore rulesets, GlobalActionContainer global) diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs similarity index 91% rename from osu.Game/Overlays/KeyBinding/KeyBindingRow.cs rename to osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index ef620df171..da789db79a 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -20,11 +20,12 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osuTK; using osuTK.Graphics; using osuTK.Input; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public class KeyBindingRow : Container, IFilterable { @@ -77,7 +78,7 @@ namespace osu.Game.Overlays.KeyBinding private RealmContextFactory realmFactory { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -100,7 +101,7 @@ namespace osu.Game.Overlays.KeyBinding EdgeEffect = new EdgeEffectParameters { Radius = 2, - Colour = colours.YellowDark.Opacity(0), + Colour = colourProvider.Highlight1.Opacity(0), Type = EdgeEffectType.Shadow, Hollow = true, }, @@ -109,13 +110,12 @@ namespace osu.Game.Overlays.KeyBinding new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.6f, + Colour = colourProvider.Background5, }, text = new OsuSpriteText { - Text = action.GetDescription(), - Margin = new MarginPadding(padding), + Text = action.GetLocalisableDescription(), + Margin = new MarginPadding(1.5f * padding), }, buttons = new FillFlowContainer { @@ -138,7 +138,8 @@ namespace osu.Game.Overlays.KeyBinding }, } } - } + }, + new HoverClickSounds() }; foreach (var b in bindings) @@ -366,12 +367,10 @@ namespace osu.Game.Overlays.KeyBinding private void updateStoreFromButton(KeyButton button) { - using (var usage = realmFactory.GetForWrite()) + using (var realm = realmFactory.CreateContext()) { - var binding = usage.Realm.Find(((IHasGuidPrimaryKey)button.KeyBinding).ID); - binding.KeyCombinationString = button.KeyBinding.KeyCombinationString; - - usage.Commit(); + var binding = realm.Find(((IHasGuidPrimaryKey)button.KeyBinding).ID); + realm.Write(() => binding.KeyCombinationString = button.KeyBinding.KeyCombinationString); } } @@ -384,7 +383,7 @@ namespace osu.Game.Overlays.KeyBinding { public CancelButton() { - Text = "Cancel"; + Text = CommonStrings.Cancel; Size = new Vector2(80, 20); } } @@ -393,7 +392,7 @@ namespace osu.Game.Overlays.KeyBinding { public ClearButton() { - Text = "Clear"; + Text = CommonStrings.Clear; Size = new Vector2(80, 20); } } @@ -405,7 +404,8 @@ namespace osu.Game.Overlays.KeyBinding private readonly Box box; public readonly OsuSpriteText Text; - private Color4 hoverColour; + [Resolved] + private OverlayColourProvider colourProvider { get; set; } private bool isBinding; @@ -448,7 +448,6 @@ namespace osu.Game.Overlays.KeyBinding box = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black }, Text = new OsuSpriteText { @@ -458,13 +457,14 @@ namespace osu.Game.Overlays.KeyBinding Origin = Anchor.Centre, Text = keyBinding.KeyCombination.ReadableString(), }, + new HoverSounds() }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - hoverColour = colours.YellowDark; + updateHoverState(); } protected override bool OnHover(HoverEvent e) @@ -483,12 +483,12 @@ namespace osu.Game.Overlays.KeyBinding { if (isBinding) { - box.FadeColour(Color4.White, transition_time, Easing.OutQuint); + box.FadeColour(colourProvider.Light2, transition_time, Easing.OutQuint); Text.FadeColour(Color4.Black, transition_time, Easing.OutQuint); } else { - box.FadeColour(IsHovered ? hoverColour : Color4.Black, transition_time, Easing.OutQuint); + box.FadeColour(IsHovered ? colourProvider.Light4 : colourProvider.Background6, transition_time, Easing.OutQuint); Text.FadeColour(IsHovered ? Color4.Black : Color4.White, transition_time, Easing.OutQuint); } } diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs similarity index 74% rename from osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs rename to osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs index 1fdc1b6574..2051af6f3c 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs @@ -7,13 +7,12 @@ 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.Bindings; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets; +using osu.Game.Localisation; using osuTK; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public abstract class KeyBindingsSubsection : SettingsSubsection { @@ -27,8 +26,7 @@ namespace osu.Game.Overlays.KeyBinding { this.variant = variant; - FlowContent.Spacing = new Vector2(0, 1); - FlowContent.Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; + FlowContent.Spacing = new Vector2(0, 3); } [BackgroundDependencyLoader] @@ -38,8 +36,8 @@ namespace osu.Game.Overlays.KeyBinding List bindings; - using (var usage = realmFactory.GetForRead()) - bindings = usage.Realm.All().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach(); + using (var realm = realmFactory.CreateContext()) + bindings = realm.All().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach(); foreach (var defaultGroup in Defaults.GroupBy(d => d.Action)) { @@ -60,14 +58,14 @@ namespace osu.Game.Overlays.KeyBinding } } - public class ResetButton : DangerousTriangleButton + public class ResetButton : DangerousSettingsButton { [BackgroundDependencyLoader] private void load() { - Text = "Reset all bindings in section"; + Text = InputSettingsStrings.ResetSectionButton; RelativeSizeAxes = Axes.X; - Width = 0.5f; + Width = 0.8f; Anchor = Anchor.TopCentre; Origin = Anchor.TopCentre; Margin = new MarginPadding { Top = 15 }; @@ -75,5 +73,8 @@ namespace osu.Game.Overlays.KeyBinding Content.CornerRadius = 5; } + + // Empty FilterTerms so that the ResetButton is visible only when the whole subsection is visible. + public override IEnumerable FilterTerms => Enumerable.Empty(); } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index e87572e2ca..a4da17c5cd 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.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; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; @@ -10,6 +11,7 @@ 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 { @@ -17,7 +19,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; @@ -27,6 +29,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input private SettingsEnumDropdown confineMouseModeSetting; private Bindable relativeMode; + private SettingsCheckbox highPrecisionMouse; + public MouseSettings(MouseHandler mouseHandler) { this.mouseHandler = mouseHandler; @@ -44,29 +48,31 @@ namespace osu.Game.Overlays.Settings.Sections.Input Children = new Drawable[] { - new SettingsCheckbox + highPrecisionMouse = 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) }, }; @@ -96,7 +102,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 { @@ -104,6 +110,17 @@ namespace osu.Game.Overlays.Settings.Sections.Input confineMouseModeSetting.TooltipText = string.Empty; } }, true); + + highPrecisionMouse.Current.BindValueChanged(highPrecision => + { + if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) + { + if (highPrecision.NewValue) + highPrecisionMouse.WarningText = MouseSettingsStrings.HighPrecisionPlatformWarning; + else + highPrecisionMouse.WarningText = null; + } + }, true); } private class SensitivitySetting : SettingsSlider @@ -117,7 +134,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private class SensitivitySlider : OsuSliderBar { - public override LocalisableString 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..3ef5ce8941 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.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; @@ -8,16 +9,24 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Handlers.Tablet; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings.Sections.Input { - internal class RotationPresetButtons : FillFlowContainer + internal class RotationPresetButtons : CompositeDrawable { + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + private readonly ITabletHandler tabletHandler; private Bindable rotation; + private readonly RotationButton[] rotationPresets = new RotationButton[preset_count]; + private const int preset_count = 4; private const int height = 50; public RotationPresetButtons(ITabletHandler tabletHandler) @@ -27,18 +36,39 @@ namespace osu.Game.Overlays.Settings.Sections.Input RelativeSizeAxes = Axes.X; Height = height; - for (int i = 0; i < 360; i += 90) + IEnumerable createColumns(int count) { - var presetRotation = i; - - Add(new RotationButton(i) + for (int i = 0; i < count; ++i) { - RelativeSizeAxes = Axes.X, - Height = height, - Width = 0.25f, - Text = $"{presetRotation}º", - Action = () => tabletHandler.Rotation.Value = presetRotation, - }); + if (i > 0) + yield return new Dimension(GridSizeMode.Absolute, 10); + + yield return new Dimension(); + } + } + + GridContainer grid; + + InternalChild = grid = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = createColumns(preset_count).ToArray() + }; + + grid.Content = new[] { new Drawable[preset_count * 2 - 1] }; + + for (int i = 0; i < preset_count; i++) + { + var rotationValue = i * 90; + + var rotationPreset = new RotationButton(rotationValue) + { + RelativeSizeAxes = Axes.Both, + Height = 1, + Text = $@"{rotationValue}º", + Action = () => tabletHandler.Rotation.Value = rotationValue, + }; + grid.Content[0][2 * i] = rotationPresets[i] = rotationPreset; } } @@ -49,16 +79,19 @@ namespace osu.Game.Overlays.Settings.Sections.Input rotation = tabletHandler.Rotation.GetBoundCopy(); rotation.BindValueChanged(val => { - foreach (var b in Children.OfType()) + foreach (var b in rotationPresets) b.IsSelected = b.Preset == val.NewValue; }, true); } - public class RotationButton : TriangleButton + public class RotationButton : RoundedButton { [Resolved] private OsuColour colours { get; set; } + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + public readonly int Preset; public RotationButton(int preset) @@ -91,18 +124,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private void updateColour() { - if (isSelected) - { - BackgroundColour = colours.BlueDark; - Triangles.ColourDark = colours.BlueDarker; - Triangles.ColourLight = colours.Blue; - } - else - { - BackgroundColour = colours.Gray4; - Triangles.ColourDark = colours.Gray5; - Triangles.ColourLight = colours.Gray6; - } + BackgroundColour = isSelected ? colours.Blue3 : colourProvider.Background3; } } } diff --git a/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs similarity index 84% rename from osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs rename to osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs index 332fb6c8fc..5246051a4a 100644 --- a/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs @@ -3,11 +3,11 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public class RulesetBindingsSection : SettingsSection { @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.KeyBinding Icon = OsuIcon.Hot }; - public override string Header => ruleset.Name; + public override LocalisableString Header => ruleset.Name; private readonly RulesetInfo ruleset; diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs index 412889d210..58abfab29c 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs @@ -4,10 +4,13 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.MatrixExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -17,6 +20,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { public class TabletAreaSelection : CompositeDrawable { + public bool IsWithinBounds { get; private set; } + private readonly ITabletHandler handler; private Container tabletContainer; @@ -109,29 +114,30 @@ namespace osu.Game.Overlays.Settings.Sections.Input areaOffset.BindTo(handler.AreaOffset); areaOffset.BindValueChanged(val => { - usableAreaContainer.MoveTo(val.NewValue, 100, Easing.OutQuint) - .OnComplete(_ => checkBounds()); // required as we are using SSDQ. + usableAreaContainer.MoveTo(val.NewValue, 100, Easing.OutQuint); + checkBounds(); }, true); areaSize.BindTo(handler.AreaSize); areaSize.BindValueChanged(val => { - usableAreaContainer.ResizeTo(val.NewValue, 100, Easing.OutQuint) - .OnComplete(_ => checkBounds()); // required as we are using SSDQ. + usableAreaContainer.ResizeTo(val.NewValue, 100, Easing.OutQuint); int x = (int)val.NewValue.X; int y = (int)val.NewValue.Y; int commonDivider = greatestCommonDivider(x, y); usableAreaText.Text = $"{(float)x / commonDivider}:{(float)y / commonDivider}"; + checkBounds(); }, true); rotation.BindTo(handler.Rotation); rotation.BindValueChanged(val => { + usableAreaContainer.RotateTo(val.NewValue, 100, Easing.OutQuint); tabletContainer.RotateTo(-val.NewValue, 800, Easing.OutQuint); - usableAreaContainer.RotateTo(val.NewValue, 100, Easing.OutQuint) - .OnComplete(_ => checkBounds()); // required as we are using SSDQ. + + checkBounds(); }, true); tablet.BindTo(handler.Tablet); @@ -169,12 +175,35 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (tablet.Value == null) return; - var usableSsdq = usableAreaContainer.ScreenSpaceDrawQuad; + // allow for some degree of floating point error, as we don't care about being perfect here. + const float lenience = 0.5f; - bool isWithinBounds = tabletContainer.ScreenSpaceDrawQuad.Contains(usableSsdq.TopLeft + new Vector2(1)) && - tabletContainer.ScreenSpaceDrawQuad.Contains(usableSsdq.BottomRight - new Vector2(1)); + var tabletArea = new Quad(-lenience, -lenience, tablet.Value.Size.X + lenience * 2, tablet.Value.Size.Y + lenience * 2); - usableFill.FadeColour(isWithinBounds ? colour.Blue : colour.RedLight, 100); + var halfUsableArea = areaSize.Value / 2; + var offset = areaOffset.Value; + + var usableAreaQuad = new Quad( + new Vector2(-halfUsableArea.X, -halfUsableArea.Y), + new Vector2(halfUsableArea.X, -halfUsableArea.Y), + new Vector2(-halfUsableArea.X, halfUsableArea.Y), + new Vector2(halfUsableArea.X, halfUsableArea.Y) + ); + + var matrix = Matrix3.Identity; + + MatrixExtensions.TranslateFromLeft(ref matrix, offset); + MatrixExtensions.RotateFromLeft(ref matrix, MathUtils.DegreesToRadians(rotation.Value)); + + usableAreaQuad *= matrix; + + IsWithinBounds = + tabletArea.Contains(usableAreaQuad.TopLeft) && + tabletArea.Contains(usableAreaQuad.TopRight) && + tabletArea.Contains(usableAreaQuad.BottomLeft) && + tabletArea.Contains(usableAreaQuad.BottomRight); + + usableFill.FadeColour(IsWithinBounds ? colour.Blue : colour.RedLight, 100); } protected override void Update() diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index d770c18878..c94b418331 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -1,22 +1,31 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Threading; +using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; +using osu.Game.Localisation; +using osu.Game.Online.Chat; namespace osu.Game.Overlays.Settings.Sections.Input { public class TabletSettings : SettingsSubsection { + public TabletAreaSelection AreaSelection { get; private set; } + private readonly ITabletHandler tabletHandler; + private readonly Bindable enabled = new BindableBool(true); + private readonly Bindable areaOffset = new Bindable(); private readonly Bindable areaSize = new Bindable(); private readonly IBindable tablet = new Bindable(); @@ -50,9 +59,9 @@ namespace osu.Game.Overlays.Settings.Sections.Input private FillFlowContainer mainSettings; - private OsuSpriteText noTabletMessage; + private FillFlowContainer noTabletMessage; - protected override string Header => "Tablet"; + protected override LocalisableString Header => TabletSettingsStrings.Tablet; public TabletSettings(ITabletHandler tabletHandler) { @@ -60,23 +69,50 @@ namespace osu.Game.Overlays.Settings.Sections.Input } [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { Children = new Drawable[] { new SettingsCheckbox { - LabelText = "Enabled", + LabelText = CommonStrings.Enabled, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Current = tabletHandler.Enabled + Current = enabled, }, - noTabletMessage = new OsuSpriteText + noTabletMessage = new FillFlowContainer { - Text = "No tablet detected!", - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS } + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, + Spacing = new Vector2(5f), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = TabletSettingsStrings.NoTabletDetected, + }, + new SettingsNoticeText(colours) + { + TextAnchor = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }.With(t => + { + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) + { + t.NewLine(); + t.AddText("If your tablet is not detected, please read "); + t.AddLink("this FAQ", LinkAction.External, RuntimeInfo.OS == RuntimeInfo.Platform.Windows + ? @"https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Windows-FAQ" + : @"https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Linux-FAQ"); + t.AddText(" for troubleshooting steps."); + } + }), + } }, mainSettings = new FillFlowContainer { @@ -87,14 +123,14 @@ namespace osu.Game.Overlays.Settings.Sections.Input Direction = FillDirection.Vertical, Children = new Drawable[] { - new TabletAreaSelection(tabletHandler) + AreaSelection = new TabletAreaSelection(tabletHandler) { RelativeSizeAxes = Axes.X, Height = 300, }, new DangerousSettingsButton { - Text = "Reset to full area", + Text = TabletSettingsStrings.ResetToFullArea, Action = () => { aspectLock.Value = false; @@ -105,7 +141,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 +150,49 @@ 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 RotationPresetButtons(tabletHandler) + { + Padding = new MarginPadding + { + Horizontal = SettingsPanel.CONTENT_MARGINS + } + }, 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 }, } @@ -162,6 +204,9 @@ namespace osu.Game.Overlays.Settings.Sections.Input { base.LoadComplete(); + enabled.BindTo(tabletHandler.Enabled); + enabled.BindValueChanged(_ => Scheduler.AddOnce(updateVisibility)); + rotation.BindTo(tabletHandler.Rotation); areaOffset.BindTo(tabletHandler.AreaOffset); @@ -207,7 +252,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input tablet.BindTo(tabletHandler.Tablet); tablet.BindValueChanged(val => { - Scheduler.AddOnce(toggleVisibility); + Scheduler.AddOnce(updateVisibility); var tab = val.NewValue; @@ -227,19 +272,18 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, true); } - private void toggleVisibility() + private void updateVisibility() { - bool tabletFound = tablet.Value != null; - - if (!tabletFound) - { - mainSettings.Hide(); - noTabletMessage.Show(); - return; - } - - mainSettings.Show(); + mainSettings.Hide(); noTabletMessage.Hide(); + + if (!tabletHandler.Enabled.Value) + return; + + if (tablet.Value != null) + mainSettings.Show(); + else + noTabletMessage.Show(); } private void applyAspectRatio(BindableNumber sizeChanged) diff --git a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs similarity index 80% rename from osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs rename to osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs index 861d59c8f4..a0f069b3bb 100644 --- a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/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 +namespace osu.Game.Overlays.Settings.Sections.Input { 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/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index 6e99891794..d282ba5318 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -9,7 +9,9 @@ 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.Localisation; using osu.Game.Overlays.Settings.Sections.Input; namespace osu.Game.Overlays.Settings.Sections @@ -18,7 +20,7 @@ namespace osu.Game.Overlays.Settings.Sections { private readonly KeyBindingPanel keyConfig; - public override string Header => "Input"; + public override LocalisableString Header => InputSettingsStrings.InputSectionHeader; [Resolved] private GameHost host { get; set; } @@ -94,13 +96,13 @@ namespace osu.Game.Overlays.Settings.Sections { new SettingsCheckbox { - LabelText = "Enabled", + LabelText = CommonStrings.Enabled, Current = handler.Enabled }, }; } - 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..e509cac2f1 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs @@ -14,6 +14,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; using osu.Framework.Screens; using osu.Game.Graphics.Containers; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Maintenance { @@ -21,7 +22,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 +92,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance }, new Drawable[] { - directorySelector = new DirectorySelector + directorySelector = new OsuDirectorySelector { RelativeSizeAxes = Axes.Both, } @@ -104,7 +105,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Origin = Anchor.Centre, Width = 300, Margin = new MarginPadding(10), - Text = "Select directory", + Text = MaintenanceSettingsStrings.SelectDirectory, Action = () => OnSelection(directorySelector.CurrentPath.Value) }, } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index a38ca81e23..43df58a8b1 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -6,10 +6,11 @@ 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; -using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Scoring; using osu.Game.Skinning; @@ -17,17 +18,17 @@ 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; - private TriangleButton importSkinsButton; - private TriangleButton importCollectionsButton; - private TriangleButton deleteBeatmapsButton; - private TriangleButton deleteScoresButton; - private TriangleButton deleteSkinsButton; - private TriangleButton restoreButton; - private TriangleButton undeleteButton; + private SettingsButton importBeatmapsButton; + private SettingsButton importScoresButton; + private SettingsButton importSkinsButton; + private SettingsButton importCollectionsButton; + private SettingsButton deleteBeatmapsButton; + private SettingsButton deleteScoresButton; + private SettingsButton deleteSkinsButton; + private SettingsButton restoreButton; + private SettingsButton undeleteButton; [BackgroundDependencyLoader(permitNulls: true)] private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] StableImportManager stableImportManager, DialogOverlay dialogOverlay) @@ -36,7 +37,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Add(importBeatmapsButton = new SettingsButton { - Text = "Import beatmaps from stable", + Text = MaintenanceSettingsStrings.ImportBeatmapsFromStable, Action = () => { importBeatmapsButton.Enabled.Value = false; @@ -47,7 +48,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Add(deleteBeatmapsButton = new DangerousSettingsButton { - Text = "Delete ALL beatmaps", + Text = MaintenanceSettingsStrings.DeleteAllBeatmaps, Action = () => { dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => @@ -62,7 +63,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Add(importScoresButton = new SettingsButton { - Text = "Import scores from stable", + Text = MaintenanceSettingsStrings.ImportScoresFromStable, Action = () => { importScoresButton.Enabled.Value = false; @@ -73,7 +74,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Add(deleteScoresButton = new DangerousSettingsButton { - Text = "Delete ALL scores", + Text = MaintenanceSettingsStrings.DeleteAllScores, Action = () => { dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => @@ -88,7 +89,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Add(importSkinsButton = new SettingsButton { - Text = "Import skins from stable", + Text = MaintenanceSettingsStrings.ImportSkinsFromStable, Action = () => { importSkinsButton.Enabled.Value = false; @@ -99,7 +100,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Add(deleteSkinsButton = new DangerousSettingsButton { - Text = "Delete ALL skins", + Text = MaintenanceSettingsStrings.DeleteAllSkins, Action = () => { dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => @@ -116,7 +117,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Add(importCollectionsButton = new SettingsButton { - Text = "Import collections from stable", + Text = MaintenanceSettingsStrings.ImportCollectionsFromStable, Action = () => { importCollectionsButton.Enabled.Value = false; @@ -127,7 +128,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Add(new DangerousSettingsButton { - Text = "Delete ALL collections", + Text = MaintenanceSettingsStrings.DeleteAllCollections, Action = () => { dialogOverlay?.Push(new DeleteAllBeatmapsDialog(collectionManager.DeleteAll)); @@ -139,7 +140,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { restoreButton = new SettingsButton { - Text = "Restore all hidden difficulties", + Text = MaintenanceSettingsStrings.RestoreAllHiddenDifficulties, Action = () => { restoreButton.Enabled.Value = false; @@ -152,7 +153,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance }, undeleteButton = new SettingsButton { - Text = "Restore all recently deleted beatmaps", + Text = MaintenanceSettingsStrings.RestoreAllRecentlyDeletedBeatmaps, Action = () => { undeleteButton.Enabled.Value = false; diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index 73c88b8e71..9410a87848 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -3,14 +3,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Maintenance; -using osuTK; namespace osu.Game.Overlays.Settings.Sections { public class MaintenanceSection : SettingsSection { - public override string Header => "Maintenance"; + public override LocalisableString Header => MaintenanceSettingsStrings.MaintenanceSectionHeader; public override Drawable CreateIcon() => new SpriteIcon { @@ -19,7 +20,6 @@ namespace osu.Game.Overlays.Settings.Sections public MaintenanceSection() { - FlowContent.Spacing = new Vector2(0, 5); Children = new Drawable[] { new GeneralSettings() diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index b0f6400d4f..351a32c72e 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -3,13 +3,15 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online { public class AlertsAndPrivacySettings : SettingsSubsection { - protected override string Header => "Alerts and Privacy"; + protected override LocalisableString Header => OnlineSettingsStrings.AlertsAndPrivacyHeader; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -18,12 +20,12 @@ namespace osu.Game.Overlays.Settings.Sections.Online { new SettingsCheckbox { - LabelText = "Show a notification when someone mentions your name", + LabelText = OnlineSettingsStrings.NotifyOnMentioned, Current = config.GetBindable(OsuSetting.NotifyOnUsernameMentioned) }, new SettingsCheckbox { - LabelText = "Show a notification when you receive a private message", + LabelText = OnlineSettingsStrings.NotifyOnPrivateMessage, 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..0207f2fd01 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs @@ -3,13 +3,15 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online { public class IntegrationSettings : SettingsSubsection { - protected override string Header => "Integrations"; + protected override LocalisableString Header => OnlineSettingsStrings.IntegrationsHeader; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -18,7 +20,7 @@ namespace osu.Game.Overlays.Settings.Sections.Online { new SettingsEnumDropdown { - LabelText = "Discord Rich Presence", + LabelText = OnlineSettingsStrings.DiscordRichPresence, Current = config.GetBindable(OsuSetting.DiscordRichPresence) } }; diff --git a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs index 59bcbe4d89..e864260cc6 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs @@ -3,13 +3,15 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online { public class WebSettings : SettingsSubsection { - protected override string Header => "Web"; + protected override LocalisableString Header => OnlineSettingsStrings.WebHeader; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -18,24 +20,24 @@ namespace osu.Game.Overlays.Settings.Sections.Online { new SettingsCheckbox { - LabelText = "Warn about opening external links", + LabelText = OnlineSettingsStrings.ExternalLinkWarning, Current = config.GetBindable(OsuSetting.ExternalLinkWarning) }, new SettingsCheckbox { - LabelText = "Prefer downloads without video", + LabelText = OnlineSettingsStrings.PreferNoVideo, Keywords = new[] { "no-video" }, Current = config.GetBindable(OsuSetting.PreferNoVideo) }, new SettingsCheckbox { - LabelText = "Automatically download beatmaps when spectating", + LabelText = OnlineSettingsStrings.AutomaticallyDownloadWhenSpectating, Keywords = new[] { "spectator" }, Current = config.GetBindable(OsuSetting.AutomaticallyDownloadWhenSpectating), }, new SettingsCheckbox { - LabelText = "Show explicit content in search results", + LabelText = OnlineSettingsStrings.ShowExplicitContent, Keywords = new[] { "nsfw", "18+", "offensive" }, Current = config.GetBindable(OsuSetting.ShowOnlineExplicitContent), } diff --git a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs index 680d11f7da..8b523b90b9 100644 --- a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs +++ b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs @@ -3,13 +3,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Online; namespace osu.Game.Overlays.Settings.Sections { public class OnlineSection : SettingsSection { - public override string Header => "Online"; + public override LocalisableString Header => OnlineSettingsStrings.OnlineSectionHeader; public override Drawable CreateIcon() => new SpriteIcon { diff --git a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs new file mode 100644 index 0000000000..b9339d5299 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Rulesets; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections +{ + public class RulesetSection : SettingsSection + { + public override LocalisableString Header => RulesetSettingsStrings.Rulesets; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Chess + }; + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance())) + { + try + { + SettingsSubsection section = ruleset.CreateSettings(); + + if (section != null) + Add(section); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load ruleset settings"); + } + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 316837d27d..00198235c5 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,8 +13,9 @@ using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Skinning; -using osuTK; +using osu.Game.Skinning.Editor; namespace osu.Game.Overlays.Settings.Sections { @@ -21,7 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections { private SkinSettingsDropdown skinDropdown; - public override string Header => "Skin"; + public override LocalisableString Header => SkinSettingsStrings.SkinSectionHeader; public override Drawable CreateIcon() => new SpriteIcon { @@ -57,41 +59,21 @@ namespace osu.Game.Overlays.Settings.Sections private IBindable> managerUpdated; private IBindable> managerRemoved; - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + [BackgroundDependencyLoader(permitNulls: true)] + private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor) { - FlowContent.Spacing = new Vector2(0, 5); - Children = new Drawable[] { - skinDropdown = new SkinSettingsDropdown(), + skinDropdown = new SkinSettingsDropdown + { + LabelText = SkinSettingsStrings.CurrentSkin + }, + new SettingsButton + { + Text = SkinSettingsStrings.SkinLayoutEditor, + Action = () => skinEditor?.Toggle(), + }, new ExportSkinButton(), - new SettingsSlider - { - LabelText = "Gameplay cursor size", - Current = config.GetBindable(OsuSetting.GameplayCursorSize), - KeyboardStep = 0.01f - }, - new SettingsCheckbox - { - LabelText = "Adjust gameplay cursor size based on current beatmap", - Current = config.GetBindable(OsuSetting.AutoCursorSize) - }, - new SettingsCheckbox - { - LabelText = "Beatmap skins", - Current = config.GetBindable(OsuSetting.BeatmapSkins) - }, - new SettingsCheckbox - { - LabelText = "Beatmap colours", - Current = config.GetBindable(OsuSetting.BeatmapColours) - }, - new SettingsCheckbox - { - LabelText = "Beatmap hitsounds", - Current = config.GetBindable(OsuSetting.BeatmapHitsounds) - }, }; managerUpdated = skins.ItemUpdated.GetBoundCopy(); @@ -193,7 +175,7 @@ namespace osu.Game.Overlays.Settings.Sections [BackgroundDependencyLoader] private void load() { - Text = "Export selected skin"; + Text = SkinSettingsStrings.ExportSkinButton; Action = export; currentSkin = skins.CurrentSkin.GetBoundCopy(); diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index a6eb008623..0afbed5df5 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -6,12 +6,13 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => UserInterfaceStrings.GeneralHeader; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -20,23 +21,23 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { new SettingsCheckbox { - LabelText = "Rotate cursor when dragging", + LabelText = UserInterfaceStrings.CursorRotation, Current = config.GetBindable(OsuSetting.CursorRotation) }, new SettingsSlider { - LabelText = "Menu cursor size", + LabelText = UserInterfaceStrings.MenuCursorSize, Current = config.GetBindable(OsuSetting.MenuCursorSize), KeyboardStep = 0.01f }, new SettingsCheckbox { - LabelText = "Parallax", + LabelText = UserInterfaceStrings.Parallax, Current = config.GetBindable(OsuSetting.MenuParallax) }, new SettingsSlider { - LabelText = "Hold-to-confirm activation time", + LabelText = UserInterfaceStrings.HoldToConfirmActivationTime, Current = config.GetBindable(OsuSetting.UIHoldActivationDelay), KeyboardStep = 50 }, diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 5f703ed5a4..40485a070c 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -4,7 +4,9 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Users; @@ -12,7 +14,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class MainMenuSettings : SettingsSubsection { - protected override string Header => "Main Menu"; + protected override LocalisableString Header => UserInterfaceStrings.MainMenuHeader; private IBindable user; @@ -27,27 +29,27 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { new SettingsCheckbox { - LabelText = "Interface voices", + LabelText = UserInterfaceStrings.InterfaceVoices, Current = config.GetBindable(OsuSetting.MenuVoice) }, new SettingsCheckbox { - LabelText = "osu! music theme", + LabelText = UserInterfaceStrings.OsuMusicTheme, Current = config.GetBindable(OsuSetting.MenuMusic) }, new SettingsEnumDropdown { - LabelText = "Intro sequence", + LabelText = UserInterfaceStrings.IntroSequence, Current = config.GetBindable(OsuSetting.IntroSequence), }, backgroundSourceDropdown = new SettingsEnumDropdown { - LabelText = "Background source", + LabelText = UserInterfaceStrings.BackgroundSource, Current = config.GetBindable(OsuSetting.MenuBackgroundSource), }, new SettingsEnumDropdown { - LabelText = "Seasonal backgrounds", + LabelText = UserInterfaceStrings.SeasonalBackgrounds, Current = config.GetBindable(OsuSetting.SeasonalBackgroundMode), } }; @@ -59,9 +61,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface user.BindValueChanged(u => { - const string not_supporter_note = "Changes to this setting will only apply with an active osu!supporter tag."; - - backgroundSourceDropdown.WarningText = u.NewValue?.IsSupporter != true ? not_supporter_note : string.Empty; + backgroundSourceDropdown.WarningText = u.NewValue?.IsSupporter != true ? UserInterfaceStrings.NotSupporterNote : default; }, true); } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index 2470c0a6c5..6290046987 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.UserInterface { @@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface private Bindable minStars; private Bindable maxStars; - protected override string Header => "Song Select"; + protected override LocalisableString Header => UserInterfaceStrings.SongSelectHeader; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -31,31 +32,31 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { new SettingsCheckbox { - LabelText = "Right mouse drag to absolute scroll", + LabelText = UserInterfaceStrings.RightMouseScroll, Current = config.GetBindable(OsuSetting.SongSelectRightMouseScroll), }, new SettingsCheckbox { - LabelText = "Show converted beatmaps", + LabelText = UserInterfaceStrings.ShowConvertedBeatmaps, Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), }, new SettingsSlider { - LabelText = "Display beatmaps from", + LabelText = UserInterfaceStrings.StarsMinimum, Current = config.GetBindable(OsuSetting.DisplayStarsMinimum), KeyboardStep = 0.1f, Keywords = new[] { "minimum", "maximum", "star", "difficulty" } }, new SettingsSlider { - LabelText = "up to", + LabelText = UserInterfaceStrings.StarsMaximum, Current = config.GetBindable(OsuSetting.DisplayStarsMaximum), KeyboardStep = 0.1f, Keywords = new[] { "minimum", "maximum", "star", "difficulty" } }, new SettingsEnumDropdown { - LabelText = "Random selection algorithm", + LabelText = UserInterfaceStrings.RandomSelectionAlgorithm, Current = config.GetBindable(OsuSetting.RandomSelectAlgorithm), } }; @@ -63,7 +64,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface private class MaximumStarsSlider : StarsSlider { - public override LocalisableString TooltipText => Current.IsDefault ? "no limit" : base.TooltipText; + public override LocalisableString TooltipText => Current.IsDefault ? UserInterfaceStrings.NoLimit : base.TooltipText; } private class StarsSlider : OsuSliderBar diff --git a/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs index 718fea5f2b..6228c4c99a 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs @@ -3,13 +3,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.UserInterface; namespace osu.Game.Overlays.Settings.Sections { public class UserInterfaceSection : SettingsSection { - public override string Header => "User Interface"; + public override LocalisableString Header => UserInterfaceStrings.UserInterfaceSectionHeader; public override Drawable CreateIcon() => new SpriteIcon { diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 87b1aa0e46..be7f2de480 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -6,11 +6,11 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings { - public class SettingsButton : TriangleButton, IHasTooltip + public class SettingsButton : RoundedButton, IHasTooltip { public SettingsButton() { diff --git a/osu.Game/Overlays/Settings/SettingsFooter.cs b/osu.Game/Overlays/Settings/SettingsFooter.cs index a815480094..ed49ce2b63 100644 --- a/osu.Game/Overlays/Settings/SettingsFooter.cs +++ b/osu.Game/Overlays/Settings/SettingsFooter.cs @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.Settings RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; - Padding = new MarginPadding { Top = 20, Bottom = 30 }; + Padding = new MarginPadding { Top = 20, Bottom = 30, Horizontal = SettingsPanel.CONTENT_MARGINS }; var modes = new List(); @@ -32,6 +32,8 @@ namespace osu.Game.Overlays.Settings { var icon = new ConstrainedIconContainer { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, Icon = ruleset.CreateInstance().CreateIcon(), Colour = Color4.Gray, Size = new Vector2(20), @@ -47,7 +49,8 @@ namespace osu.Game.Overlays.Settings Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Direction = FillDirection.Full, - AutoSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Children = modes, Spacing = new Vector2(5), Padding = new MarginPadding { Bottom = 10 }, diff --git a/osu.Game/Overlays/Settings/SettingsHeader.cs b/osu.Game/Overlays/Settings/SettingsHeader.cs index a7f1cef74c..69b7b69a29 100644 --- a/osu.Game/Overlays/Settings/SettingsHeader.cs +++ b/osu.Game/Overlays/Settings/SettingsHeader.cs @@ -22,7 +22,7 @@ namespace osu.Game.Overlays.Settings } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -39,7 +39,7 @@ namespace osu.Game.Overlays.Settings new OsuSpriteText { Text = heading, - Font = OsuFont.GetFont(size: 40), + Font = OsuFont.TorusAlternate.With(size: 40), Margin = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, @@ -48,7 +48,7 @@ namespace osu.Game.Overlays.Settings }, new OsuSpriteText { - Colour = colours.Pink, + Colour = colourProvider.Content2, Text = subheading, Font = OsuFont.GetFont(size: 18), Margin = new MarginPadding diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 15a0a42d31..5282217013 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -61,24 +61,23 @@ namespace osu.Game.Overlays.Settings /// Text to be displayed at the bottom of this . /// Generally used to recommend the user change their setting as the current one is considered sub-optimal. /// - public string WarningText + public LocalisableString? WarningText { set { + bool hasValue = !string.IsNullOrWhiteSpace(value.ToString()); + if (warningText == null) { + if (!hasValue) + return; + // construct lazily for cases where the label is not needed (may be provided by the Control). - FlowContent.Add(warningText = new OsuTextFlowContainer - { - Colour = colours.Yellow, - Margin = new MarginPadding { Bottom = 5 }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }); + FlowContent.Add(warningText = new SettingsNoticeText(colours) { Margin = new MarginPadding { Bottom = 5 } }); } - warningText.Alpha = string.IsNullOrWhiteSpace(value) ? 0 : 1; - warningText.Text = value; + warningText.Alpha = hasValue ? 1 : 0; + warningText.Text = value.ToString(); // TODO: Remove ToString() call after TextFlowContainer supports localisation (see https://github.com/ppy/osu-framework/issues/4636). } } @@ -94,7 +93,7 @@ namespace osu.Game.Overlays.Settings public bool MatchingFilter { - set => this.FadeTo(value ? 1 : 0); + set => Alpha = value ? 1 : 0; } public bool FilteringActive { get; set; } @@ -103,15 +102,12 @@ namespace osu.Game.Overlays.Settings protected SettingsItem() { - RestoreDefaultValueButton restoreDefaultButton; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Padding = new MarginPadding { Right = SettingsPanel.CONTENT_MARGINS }; InternalChildren = new Drawable[] { - restoreDefaultButton = new RestoreDefaultValueButton(), FlowContent = new FillFlowContainer { RelativeSizeAxes = Axes.X, @@ -124,15 +120,25 @@ namespace osu.Game.Overlays.Settings }, }; - // all bindable logic is in constructor intentionally to support "CreateSettingsControls" being used in a context it is + // IMPORTANT: 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(); + } + + [BackgroundDependencyLoader] + private void load() + { + // intentionally done before LoadComplete to avoid overhead. + if (ShowsDefaultIndicator) + { + AddInternal(new RestoreDefaultValueButton + { + Current = controlWithCurrent.Current, + }); } } diff --git a/osu.Game/Overlays/Settings/SettingsNoticeText.cs b/osu.Game/Overlays/Settings/SettingsNoticeText.cs new file mode 100644 index 0000000000..76ecf7edd4 --- /dev/null +++ b/osu.Game/Overlays/Settings/SettingsNoticeText.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; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Settings +{ + public class SettingsNoticeText : LinkFlowContainer + { + public SettingsNoticeText(OsuColour colours) + : base(s => s.Colour = colours.Yellow) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + } +} diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index 2fbe522479..d36aa2bfc2 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -36,6 +36,7 @@ namespace osu.Game.Overlays.Settings { numberBox = new OutlinedNumberBox { + LengthLimit = 9, // limited to less than a value that could overflow int32 backing. Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, CommitOnFocusLost = true diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index 4143605c28..0ae353602e 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -4,12 +4,15 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK.Graphics; +using osuTK; namespace osu.Game.Overlays.Settings { @@ -18,15 +21,20 @@ namespace osu.Game.Overlays.Settings protected FillFlowContainer FlowContent; protected override Container Content => FlowContent; + private IBindable selectedSection; + + private OsuSpriteText header; + public abstract Drawable CreateIcon(); - public abstract string Header { get; } + public abstract LocalisableString Header { get; } public IEnumerable FilterableChildren => Children.OfType(); - public virtual IEnumerable FilterTerms => new[] { Header }; + public virtual IEnumerable FilterTerms => new[] { Header.ToString() }; - private const int header_size = 26; - private const int margin = 20; - private const int border_size = 2; + public const int ITEM_SPACING = 14; + + private const int header_size = 24; + private const int border_size = 4; public bool MatchingFilter { @@ -35,9 +43,11 @@ namespace osu.Game.Overlays.Settings public bool FilteringActive { get; set; } + [Resolved] + private SettingsPanel settingsPanel { get; set; } + protected SettingsSection() { - Margin = new MarginPadding { Top = margin }; AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; @@ -45,8 +55,9 @@ namespace osu.Game.Overlays.Settings { Margin = new MarginPadding { - Top = header_size + Top = 36 }, + Spacing = new Vector2(0, ITEM_SPACING), Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -54,13 +65,14 @@ namespace osu.Game.Overlays.Settings } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { AddRangeInternal(new Drawable[] { new Box { - Colour = new Color4(0, 0, 0, 255), + Name = "separator", + Colour = colourProvider.Background6, RelativeSizeAxes = Axes.X, Height = border_size, }, @@ -68,28 +80,71 @@ namespace osu.Game.Overlays.Settings { Padding = new MarginPadding { - Top = margin + border_size, - Bottom = 10, + Top = 28, + Bottom = 40, }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Children = new Drawable[] { - new OsuSpriteText + header = new OsuSpriteText { - Font = OsuFont.GetFont(size: header_size), + Font = OsuFont.TorusAlternate.With(size: header_size), Text = Header, - Colour = colours.Yellow, Margin = new MarginPadding { - Left = SettingsPanel.CONTENT_MARGINS, - Right = SettingsPanel.CONTENT_MARGINS + Horizontal = SettingsPanel.CONTENT_MARGINS } }, FlowContent } }, }); + + selectedSection = settingsPanel.CurrentSection.GetBoundCopy(); + selectedSection.BindValueChanged(_ => updateContentFade(), true); + } + + private bool isCurrentSection => selectedSection.Value == this; + + protected override bool OnHover(HoverEvent e) + { + updateContentFade(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateContentFade(); + base.OnHoverLost(e); + } + + protected override bool OnClick(ClickEvent e) + { + if (!isCurrentSection) + settingsPanel.SectionsContainer.ScrollTo(this); + + return base.OnClick(e); + } + + protected override bool ShouldBeConsideredForInput(Drawable child) => + // only the current section should accept input. + // this provides the behaviour of the first click scrolling the target section to the centre of the screen. + isCurrentSection; + + private void updateContentFade() + { + float contentFade = 1; + float headerFade = 1; + + if (!isCurrentSection) + { + contentFade = 0.25f; + headerFade = IsHovered ? 0.5f : 0.25f; + } + + header.FadeTo(headerFade, 500, Easing.OutQuint); + FlowContent.FadeTo(contentFade, 500, Easing.OutQuint); } } } diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 6abf6283b9..c2cf08ac98 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,15 @@ 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 }; + + // FilterTerms should contains both original string and localised string for user to search. + // Since LocalisableString is unable to get original string at this time (2021-08-14), + // only call .ToString() to use localised one. + // TODO: Update here when FilterTerms accept LocalisableString. + public virtual IEnumerable FilterTerms => new[] { Header.ToString() }; public bool MatchingFilter { @@ -40,13 +46,17 @@ namespace osu.Game.Overlays.Settings FlowContent = new FillFlowContainer { + Margin = new MarginPadding { Top = SettingsSection.ITEM_SPACING }, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 8), + Spacing = new Vector2(0, SettingsSection.ITEM_SPACING), RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, }; } + private const int header_height = 43; + private const int header_font_size = 20; + [BackgroundDependencyLoader] private void load() { @@ -54,9 +64,9 @@ namespace osu.Game.Overlays.Settings { new OsuSpriteText { - Text = Header.ToUpperInvariant(), - Margin = new MarginPadding { Vertical = 30, Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, - Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = Header, + Margin = new MarginPadding { Vertical = (header_height - header_font_size) * 0.5f, Horizontal = SettingsPanel.CONTENT_MARGINS }, + Font = OsuFont.GetFont(size: header_font_size), }, FlowContent }); diff --git a/osu.Game/Overlays/Settings/SettingsTextBox.cs b/osu.Game/Overlays/Settings/SettingsTextBox.cs index d28dbf1068..68562802cf 100644 --- a/osu.Game/Overlays/Settings/SettingsTextBox.cs +++ b/osu.Game/Overlays/Settings/SettingsTextBox.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.Graphics; namespace osu.Game.Overlays.Settings @@ -13,5 +15,17 @@ namespace osu.Game.Overlays.Settings RelativeSizeAxes = Axes.X, CommitOnFocusLost = true }; + + public override Bindable Current + { + get => base.Current; + set + { + if (value.Default == null) + throw new InvalidOperationException($"Bindable settings of type {nameof(Bindable)} should have a non-null default value."); + + base.Current = value; + } + } } } diff --git a/osu.Game/Overlays/Settings/Sidebar.cs b/osu.Game/Overlays/Settings/Sidebar.cs index 4ca6e2ec42..93b1b19b17 100644 --- a/osu.Game/Overlays/Settings/Sidebar.cs +++ b/osu.Game/Overlays/Settings/Sidebar.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,22 +16,23 @@ using osuTK; namespace osu.Game.Overlays.Settings { - public class Sidebar : Container, IStateful + public class Sidebar : Container, IStateful { - private readonly FillFlowContainer content; - public const float DEFAULT_WIDTH = Toolbar.Toolbar.HEIGHT * 1.4f; + private readonly Box background; + private readonly FillFlowContainer content; + public const float DEFAULT_WIDTH = 70; public const int EXPANDED_WIDTH = 200; public event Action StateChanged; - protected override Container Content => content; + protected override Container Content => content; public Sidebar() { RelativeSizeAxes = Axes.Y; InternalChildren = new Drawable[] { - new Box + background = new Box { Colour = OsuColour.Gray(0.02f), RelativeSizeAxes = Axes.Both, @@ -39,7 +41,7 @@ namespace osu.Game.Overlays.Settings { Children = new[] { - content = new FillFlowContainer + content = new FillFlowContainer { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, @@ -52,6 +54,12 @@ namespace osu.Game.Overlays.Settings }; } + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background5; + } + private ScheduledDelegate expandEvent; private ExpandedState state; @@ -80,8 +88,6 @@ namespace osu.Game.Overlays.Settings { public SidebarScrollContainer() { - Content.Anchor = Anchor.CentreLeft; - Content.Origin = Anchor.CentreLeft; RelativeSizeAxes = Axes.Both; ScrollbarVisible = false; } diff --git a/osu.Game/Overlays/Settings/SidebarButton.cs b/osu.Game/Overlays/Settings/SidebarButton.cs index 30a53b351d..1a34143e1f 100644 --- a/osu.Game/Overlays/Settings/SidebarButton.cs +++ b/osu.Game/Overlays/Settings/SidebarButton.cs @@ -1,107 +1,40 @@ -// 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 osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { - public class SidebarButton : OsuButton + public abstract class SidebarButton : OsuButton { - private readonly ConstrainedIconContainer iconContainer; - private readonly SpriteText headerText; - private readonly Box selectionIndicator; - private readonly Container text; + protected const double FADE_DURATION = 500; - private SettingsSection section; - - public SettingsSection Section - { - get => section; - set - { - section = value; - headerText.Text = value.Header; - iconContainer.Icon = value.CreateIcon(); - } - } - - private bool selected; - - public bool Selected - { - get => selected; - set - { - selected = value; - - if (selected) - { - selectionIndicator.FadeIn(50); - text.FadeColour(Color4.White, 50); - } - else - { - selectionIndicator.FadeOut(50); - text.FadeColour(OsuColour.Gray(0.6f), 50); - } - } - } - - public SidebarButton() - { - Height = Sidebar.DEFAULT_WIDTH; - RelativeSizeAxes = Axes.X; - - BackgroundColour = Color4.Black; - - AddRange(new Drawable[] - { - text = new Container - { - Width = Sidebar.DEFAULT_WIDTH, - RelativeSizeAxes = Axes.Y, - Colour = OsuColour.Gray(0.6f), - Children = new Drawable[] - { - headerText = new OsuSpriteText - { - Position = new Vector2(Sidebar.DEFAULT_WIDTH + 10, 0), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - iconContainer = new ConstrainedIconContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(20), - }, - } - }, - selectionIndicator = new Box - { - Alpha = 0, - RelativeSizeAxes = Axes.Y, - Width = 5, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - }, - }); - } + [Resolved] + protected OverlayColourProvider ColourProvider { get; private set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - selectionIndicator.Colour = colours.Yellow; + BackgroundColour = ColourProvider.Background5; } + + protected override void LoadComplete() + { + base.LoadComplete(); + UpdateState(); + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + UpdateState(); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) => UpdateState(); + + protected abstract void UpdateState(); } } diff --git a/osu.Game/Overlays/Settings/SidebarIconButton.cs b/osu.Game/Overlays/Settings/SidebarIconButton.cs new file mode 100644 index 0000000000..fd57996b1b --- /dev/null +++ b/osu.Game/Overlays/Settings/SidebarIconButton.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osuTK; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Settings +{ + public class SidebarIconButton : SidebarButton + { + private const float selection_indicator_height_active = 18; + private const float selection_indicator_height_inactive = 4; + + private readonly ConstrainedIconContainer iconContainer; + private readonly SpriteText headerText; + private readonly CircularContainer selectionIndicator; + private readonly Container textIconContent; + + // always consider as part of flow, even when not visible (for the sake of the initial animation). + public override bool IsPresent => true; + + private SettingsSection section; + + public SettingsSection Section + { + get => section; + set + { + section = value; + headerText.Text = value.Header; + iconContainer.Icon = value.CreateIcon(); + } + } + + private bool selected; + + public bool Selected + { + get => selected; + set + { + selected = value; + + if (IsLoaded) + UpdateState(); + } + } + + public SidebarIconButton() + { + RelativeSizeAxes = Axes.X; + Height = 46; + + AddRange(new Drawable[] + { + textIconContent = new Container + { + Width = Sidebar.DEFAULT_WIDTH, + RelativeSizeAxes = Axes.Y, + Colour = OsuColour.Gray(0.6f), + Children = new Drawable[] + { + headerText = new OsuSpriteText + { + Position = new Vector2(Sidebar.DEFAULT_WIDTH + 10, 0), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + iconContainer = new ConstrainedIconContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(20), + }, + } + }, + selectionIndicator = new CircularContainer + { + Alpha = 0, + Width = 4, + Height = selection_indicator_height_inactive, + Masking = true, + CornerRadius = 1.5f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding + { + Left = 9, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White + } + }, + }); + } + + [BackgroundDependencyLoader] + private void load() + { + selectionIndicator.Colour = ColourProvider.Highlight1; + } + + protected override void UpdateState() + { + if (Selected) + { + textIconContent.FadeColour(ColourProvider.Content1, FADE_DURATION, Easing.OutQuint); + + selectionIndicator.FadeIn(FADE_DURATION, Easing.OutQuint); + selectionIndicator.ResizeHeightTo(selection_indicator_height_active, FADE_DURATION, Easing.OutElasticHalf); + } + else + { + textIconContent.FadeColour(IsHovered ? ColourProvider.Light1 : ColourProvider.Light3, FADE_DURATION, Easing.OutQuint); + + selectionIndicator.FadeOut(FADE_DURATION, Easing.OutQuint); + selectionIndicator.ResizeHeightTo(selection_indicator_height_inactive, FADE_DURATION, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 8c21880cc6..c84cba8189 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -6,9 +6,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections; +using osu.Game.Overlays.Settings.Sections.Input; using osuTK.Graphics; using System.Collections.Generic; -using System.Linq; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Game.Localisation; @@ -24,12 +24,13 @@ namespace osu.Game.Overlays protected override IEnumerable CreateSections() => new SettingsSection[] { new GeneralSection(), - new GraphicsSection(), - new AudioSection(), + new SkinSection(), new InputSection(createSubPanel(new KeyBindingPanel())), new UserInterfaceSection(), new GameplaySection(), - new SkinSection(), + new RulesetSection(), + new AudioSection(), + new GraphicsSection(), new OnlineSection(), new MaintenanceSection(), new DebugSection(), @@ -37,6 +38,8 @@ namespace osu.Game.Overlays private readonly List subPanels = new List(); + private SettingsSubPanel lastOpenedSubPanel; + protected override Drawable CreateHeader() => new SettingsHeader(Title, Description); protected override Drawable CreateFooter() => new SettingsFooter(); @@ -45,21 +48,21 @@ namespace osu.Game.Overlays { } - public override bool AcceptsFocus => subPanels.All(s => s.State.Value != Visibility.Visible); + public override bool AcceptsFocus => lastOpenedSubPanel == null || lastOpenedSubPanel.State.Value == Visibility.Hidden; private T createSubPanel(T subPanel) where T : SettingsSubPanel { subPanel.Depth = 1; subPanel.Anchor = Anchor.TopRight; - subPanel.State.ValueChanged += subPanelStateChanged; + subPanel.State.ValueChanged += e => subPanelStateChanged(subPanel, e); subPanels.Add(subPanel); return subPanel; } - private void subPanelStateChanged(ValueChangedEvent state) + private void subPanelStateChanged(SettingsSubPanel panel, ValueChangedEvent state) { switch (state.NewValue) { @@ -67,7 +70,9 @@ namespace osu.Game.Overlays Sidebar?.FadeColour(Color4.DarkGray, 300, Easing.OutQuint); SectionsContainer.FadeOut(300, Easing.OutQuint); - ContentContainer.MoveToX(-WIDTH, 500, Easing.OutQuint); + ContentContainer.MoveToX(-PANEL_WIDTH, 500, Easing.OutQuint); + + lastOpenedSubPanel = panel; break; case Visibility.Hidden: @@ -79,7 +84,7 @@ namespace osu.Game.Overlays } } - protected override float ExpandedPosition => subPanels.Any(s => s.State.Value == Visibility.Visible) ? -WIDTH : base.ExpandedPosition; + protected override float ExpandedPosition => lastOpenedSubPanel?.State.Value == Visibility.Visible ? -PANEL_WIDTH : base.ExpandedPosition; [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index eae828c142..0ceb7fc50d 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -4,52 +4,67 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; namespace osu.Game.Overlays { + [Cached] public abstract class SettingsPanel : OsuFocusedOverlayContainer { - public const float CONTENT_MARGINS = 15; + public const float CONTENT_MARGINS = 20; public const float TRANSITION_LENGTH = 600; private const float sidebar_width = Sidebar.DEFAULT_WIDTH; - public const float WIDTH = 400; + /// + /// The width of the settings panel content, excluding the sidebar. + /// + public const float PANEL_WIDTH = 400; + + /// + /// The full width of the settings panel, including the sidebar. + /// + public const float WIDTH = sidebar_width + PANEL_WIDTH; protected Container ContentContainer; protected override Container Content => ContentContainer; protected Sidebar Sidebar; - private SidebarButton selectedSidebarButton; + private SidebarIconButton selectedSidebarButton; - protected SettingsSectionsContainer SectionsContainer; + public SettingsSectionsContainer SectionsContainer { get; private set; } private SeekLimitedSearchTextBox searchTextBox; protected override string PopInSampleName => "UI/settings-pop-in"; - /// - /// Provide a source for the toolbar height. - /// - public Func GetToolbarHeight; - private readonly bool showSidebar; + private LoadingLayer loading; + + private readonly List loadableSections = new List(); + + private Task sectionsLoadingTask; + + public IBindable CurrentSection = new Bindable(); + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + protected SettingsPanel(bool showSidebar) { this.showSidebar = showSidebar; @@ -64,7 +79,8 @@ namespace osu.Game.Overlays { InternalChild = ContentContainer = new NonMaskedContent { - Width = WIDTH, + X = -WIDTH + ExpandedPosition, + Width = PANEL_WIDTH, RelativeSizeAxes = Axes.Y, Children = new Drawable[] { @@ -74,72 +90,58 @@ namespace osu.Game.Overlays Origin = Anchor.TopRight, Scale = new Vector2(2, 1), // over-extend to the left for transitions RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.05f), + Colour = colourProvider.Background4, Alpha = 1, }, - SectionsContainer = new SettingsSectionsContainer + loading = new LoadingLayer { - Masking = true, - RelativeSizeAxes = Axes.Both, - ExpandableHeader = CreateHeader(), - FixedHeader = searchTextBox = new SeekLimitedSearchTextBox - { - RelativeSizeAxes = Axes.X, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Width = 0.95f, - Margin = new MarginPadding - { - Top = 20, - Bottom = 20 - }, - }, - Footer = CreateFooter() - }, + State = { Value = Visibility.Visible } + } } }; + Add(SectionsContainer = new SettingsSectionsContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, + ExpandableHeader = CreateHeader(), + SelectedSection = { BindTarget = CurrentSection }, + FixedHeader = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = 20, + Horizontal = CONTENT_MARGINS + }, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = searchTextBox = new SeekLimitedSearchTextBox + { + RelativeSizeAxes = Axes.X, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + } + }, + Footer = CreateFooter().With(f => f.Alpha = 0) + }); + if (showSidebar) { AddInternal(Sidebar = new Sidebar { Width = sidebar_width }); - - SectionsContainer.SelectedSection.ValueChanged += section => - { - selectedSidebarButton.Selected = false; - selectedSidebarButton = Sidebar.Children.Single(b => b.Section == section.NewValue); - selectedSidebarButton.Selected = true; - }; } - searchTextBox.Current.ValueChanged += term => SectionsContainer.SearchContainer.SearchTerm = term.NewValue; - CreateSections()?.ForEach(AddSection); } protected void AddSection(SettingsSection section) { - SectionsContainer.Add(section); + if (IsLoaded) + // just to keep things simple. can be accommodated for if we ever need it. + throw new InvalidOperationException("All sections must be added before the panel is loaded."); - if (Sidebar != null) - { - var button = new SidebarButton - { - Section = section, - Action = () => - { - SectionsContainer.ScrollTo(section); - Sidebar.State = ExpandedState.Contracted; - }, - }; - - Sidebar.Add(button); - - if (selectedSidebarButton == null) - { - selectedSidebarButton = Sidebar.Children.First(); - selectedSidebarButton.Selected = true; - } - } + loadableSections.Add(section); } protected virtual Drawable CreateHeader() => new Container(); @@ -152,6 +154,12 @@ namespace osu.Game.Overlays ContentContainer.MoveToX(ExpandedPosition, TRANSITION_LENGTH, Easing.OutQuint); + // delay load enough to ensure it doesn't overlap with the initial animation. + // this is done as there is still a brief stutter during load completion which is more visible if the transition is in progress. + // the eventual goal would be to remove the need for this by splitting up load into smaller work pieces, or fixing the remaining + // load complete overheads. + Scheduler.AddDelayed(loadSections, TRANSITION_LENGTH / 3); + Sidebar?.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); this.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); @@ -187,7 +195,80 @@ namespace osu.Game.Overlays base.UpdateAfterChildren(); ContentContainer.Margin = new MarginPadding { Left = Sidebar?.DrawWidth ?? 0 }; - Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; + } + + private const double fade_in_duration = 1000; + + private void loadSections() + { + if (sectionsLoadingTask != null) + return; + + sectionsLoadingTask = LoadComponentsAsync(loadableSections, sections => + { + SectionsContainer.AddRange(sections); + SectionsContainer.Footer.FadeInFromZero(fade_in_duration, Easing.OutQuint); + SectionsContainer.SearchContainer.FadeInFromZero(fade_in_duration, Easing.OutQuint); + + loading.Hide(); + + searchTextBox.Current.BindValueChanged(term => SectionsContainer.SearchTerm = term.NewValue, true); + searchTextBox.TakeFocus(); + + loadSidebarButtons(); + }); + } + + private void loadSidebarButtons() + { + if (Sidebar == null) + return; + + LoadComponentsAsync(createSidebarButtons(), buttons => + { + float delay = 0; + + foreach (var button in buttons) + { + Sidebar.Add(button); + + button.FadeOut() + .Delay(delay) + .FadeInFromZero(fade_in_duration, Easing.OutQuint); + + delay += 40; + } + + SectionsContainer.SelectedSection.BindValueChanged(section => + { + if (selectedSidebarButton != null) + selectedSidebarButton.Selected = false; + + selectedSidebarButton = Sidebar.Children.FirstOrDefault(b => b.Section == section.NewValue); + + if (selectedSidebarButton != null) + selectedSidebarButton.Selected = true; + }, true); + }); + } + + private IEnumerable createSidebarButtons() + { + foreach (var section in SectionsContainer) + { + yield return new SidebarIconButton + { + Section = section, + Action = () => + { + if (!SectionsContainer.IsLoaded) + return; + + SectionsContainer.ScrollTo(section); + Sidebar.State = ExpandedState.Contracted; + }, + }; + } } private class NonMaskedContent : Container @@ -200,6 +281,16 @@ namespace osu.Game.Overlays { public SearchContainer SearchContainer; + public string SearchTerm + { + get => SearchContainer.SearchTerm; + set + { + SearchContainer.SearchTerm = value; + InvalidateScrollPosition(); + } + } + protected override FlowContainer CreateScrollContentContainer() => SearchContainer = new SearchContainer { @@ -208,11 +299,12 @@ namespace osu.Game.Overlays Direction = FillDirection.Vertical, }; - public SettingsSectionsContainer() + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { HeaderBackground = new Box { - Colour = Color4.Black, + Colour = colourProvider.Background4, RelativeSizeAxes = Axes.Both }; } diff --git a/osu.Game/Overlays/SettingsSubPanel.cs b/osu.Game/Overlays/SettingsSubPanel.cs index 1fa233d9d4..a65d792a9f 100644 --- a/osu.Game/Overlays/SettingsSubPanel.cs +++ b/osu.Game/Overlays/SettingsSubPanel.cs @@ -7,10 +7,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays { @@ -34,18 +32,18 @@ namespace osu.Game.Overlays protected override bool DimMainContent => false; // dimming is handled by main overlay - private class BackButton : OsuButton + private class BackButton : SidebarButton { + private Container content; + [BackgroundDependencyLoader] private void load() { Size = new Vector2(Sidebar.DEFAULT_WIDTH); - BackgroundColour = Color4.Black; - AddRange(new Drawable[] { - new Container + content = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -71,6 +69,11 @@ namespace osu.Game.Overlays } }); } + + protected override void UpdateState() + { + content.FadeColour(IsHovered ? ColourProvider.Light1 : ColourProvider.Light3, FADE_DURATION, Easing.OutQuint); + } } } } diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index 3d88171ba7..dc0b06b255 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -18,7 +18,7 @@ using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class Toolbar : VisibilityContainer, IKeyBindingHandler + public class Toolbar : OverlayContainer, IKeyBindingHandler { public const float HEIGHT = 40; public const float TOOLTIP_HEIGHT = 30; @@ -41,6 +41,8 @@ namespace osu.Game.Overlays.Toolbar // Toolbar and its components need keyboard input even when hidden. public override bool PropagateNonPositionalInputSubTree => true; + protected override bool BlockScrollInput => false; + public Toolbar() { RelativeSizeAxes = Axes.X; @@ -176,12 +178,12 @@ namespace osu.Game.Overlays.Toolbar this.FadeOut(transition_time, Easing.InQuint); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { if (OverlayActivationMode.Value == OverlayActivation.Disabled) return false; - switch (action) + switch (e.Action) { case GlobalAction.ToggleToolbar: hiddenByUser = State.Value == Visibility.Visible; // set before toggling to allow the operation to always succeed. @@ -192,7 +194,7 @@ namespace osu.Game.Overlays.Toolbar return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 4a33f9e296..dd554200ca 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -184,18 +184,18 @@ namespace osu.Game.Overlays.Toolbar tooltipContainer.FadeOut(100); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action == Hotkey) + if (e.Action == Hotkey) { - Click(); + TriggerClick(); return true; } return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs index 564fd65719..a70a0d8a71 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs @@ -62,7 +62,7 @@ namespace osu.Game.Overlays.Toolbar protected override bool OnClick(ClickEvent e) { - Parent.Click(); + Parent.TriggerClick(); return base.OnClick(e); } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index 165c095514..5d4430caa2 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -32,7 +32,7 @@ namespace osu.Game.Overlays.Toolbar Add(new OpaqueBackground { Depth = 1 }); - Flow.Add(avatar = new UpdateableAvatar(openOnClick: false) + Flow.Add(avatar = new UpdateableAvatar(isInteractive: false) { Masking = true, Size = new Vector2(32), diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index 299a14b250..b0327987f2 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -40,6 +40,8 @@ namespace osu.Game.Overlays public void ShowUser(int userId) => ShowUser(new User { Id = userId }); + public void ShowUser(string username) => ShowUser(new User { Username = username }); + public void ShowUser(User user, bool fetchOnline = true) { if (user == User.SYSTEM_USER) @@ -116,7 +118,7 @@ namespace osu.Game.Overlays if (fetchOnline) { - userReq = new GetUserRequest(user.Id); + userReq = user.Id > 1 ? new GetUserRequest(user.Id) : new GetUserRequest(user.Username); userReq.Success += userLoadComplete; API.Queue(userReq); } diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index ae9c2eb394..4129b46ce3 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -19,25 +19,27 @@ namespace osu.Game.Overlays.Volume private ScheduledDelegate keyRepeat; - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.DecreaseVolume: case GlobalAction.IncreaseVolume: keyRepeat?.Cancel(); - keyRepeat = this.BeginKeyRepeat(Scheduler, () => ActionRequested?.Invoke(action), 150); + keyRepeat = this.BeginKeyRepeat(Scheduler, () => ActionRequested?.Invoke(e.Action), 150); return true; case GlobalAction.ToggleMute: - ActionRequested?.Invoke(action); + case GlobalAction.NextVolumeMeter: + case GlobalAction.PreviousVolumeMeter: + ActionRequested?.Invoke(e.Action); return true; } return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { keyRepeat?.Cancel(); } @@ -52,7 +54,7 @@ namespace osu.Game.Overlays.Volume return true; } - public bool OnScroll(GlobalAction action, float amount, bool isPrecise) => - ScrollActionRequested?.Invoke(action, amount, isPrecise) ?? false; + public bool OnScroll(KeyBindingScrollEvent e) => + ScrollActionRequested?.Invoke(e.Action, e.ScrollAmount, e.IsPrecise) ?? false; } } diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index 1832841476..ff28b45ebb 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -3,10 +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; @@ -19,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; @@ -38,9 +41,33 @@ namespace osu.Game.Overlays.Volume private OsuSpriteText text; private BufferedContainer maxGlow; - private Sample sample; + 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; @@ -53,7 +80,8 @@ namespace osu.Game.Overlays.Volume [BackgroundDependencyLoader] private void load(OsuColour colours, AudioManager audio) { - sample = audio.Samples.Get(@"UI/notch-tick"); + hoverSample = audio.Samples.Get($"UI/{HoverSampleSet.Button.GetDescription()}-hover"); + notchSample = audio.Samples.Get(@"UI/notch-tick"); sampleLastPlaybackTime = Time.Current; Color4 backgroundColour = colours.Gray1; @@ -75,7 +103,6 @@ namespace osu.Game.Overlays.Volume { new BufferedContainer { - Alpha = 0.9f, RelativeSizeAxes = Axes.Both, Children = new Drawable[] { @@ -147,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, @@ -171,7 +216,6 @@ namespace osu.Game.Overlays.Volume { new Box { - Alpha = 0.9f, RelativeSizeAxes = Axes.Both, Colour = backgroundColour, }, @@ -233,7 +277,7 @@ namespace osu.Game.Overlays.Volume if (Time.Current - sampleLastPlaybackTime <= tick_debounce_time) return; - var channel = sample.GetChannel(); + var channel = notchSample.GetChannel(); channel.Frequency.Value = 0.99f + RNG.NextDouble(0.02f) + displayVolume * 0.1f; @@ -305,31 +349,36 @@ namespace osu.Game.Overlays.Volume return true; } - private const float transition_length = 500; + protected override bool OnMouseMove(MouseMoveEvent e) + { + State = SelectionState.Selected; + return base.OnMouseMove(e); + } protected override bool OnHover(HoverEvent e) { - this.ScaleTo(1.04f, transition_length, Easing.OutExpo); + State = SelectionState.Selected; return false; } protected override void OnHoverLost(HoverLostEvent e) { - this.ScaleTo(1f, transition_length, Easing.OutExpo); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { if (!IsHovered) return false; - switch (action) + switch (e.Action) { case GlobalAction.SelectPrevious: + State = SelectionState.Selected; adjust(1, false); return true; case GlobalAction.SelectNext: + State = SelectionState.Selected; adjust(-1, false); return true; } @@ -337,8 +386,25 @@ namespace osu.Game.Overlays.Volume return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } + + 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/WikiMarkdownImageBlock.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs index 1a4f6087c7..501e00bc00 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs @@ -14,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; @@ -31,16 +31,20 @@ namespace osu.Game.Overlays.Wiki.Markdown [BackgroundDependencyLoader] private void load() { + MarkdownTextFlowContainer textFlow; + Children = new Drawable[] { new BlockMarkdownImage(linkInline), - parentTextComponent.CreateSpriteText().With(t => + textFlow = parentFlowComponent.CreateTextFlow().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 diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index 6b8cba48b4..3e81d2cffe 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -5,15 +5,19 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Wiki { public class WikiHeader : BreadcrumbControlOverlayHeader { - private const string index_page_string = "index"; private const string index_path = "Main_Page"; + public static LocalisableString IndexPageString => LayoutStrings.HeaderHelpIndex; + public readonly Bindable WikiPageData = new Bindable(); public Action ShowIndexPage; @@ -21,8 +25,8 @@ namespace osu.Game.Overlays.Wiki public WikiHeader() { - TabControl.AddItem(index_page_string); - Current.Value = index_page_string; + TabControl.AddItem(IndexPageString); + Current.Value = IndexPageString; WikiPageData.BindValueChanged(onWikiPageChange); Current.BindValueChanged(onCurrentChange); @@ -36,11 +40,11 @@ namespace osu.Game.Overlays.Wiki TabControl.Clear(); Current.Value = null; - TabControl.AddItem(index_page_string); + TabControl.AddItem(IndexPageString); if (e.NewValue.Path == index_path) { - Current.Value = index_page_string; + Current.Value = IndexPageString; return; } @@ -51,12 +55,12 @@ 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; - if (e.NewValue == index_page_string) + if (e.NewValue == IndexPageString) { ShowIndexPage?.Invoke(); return; @@ -73,8 +77,8 @@ namespace osu.Game.Overlays.Wiki { public WikiHeaderTitle() { - Title = "wiki"; - Description = "knowledge base"; + Title = PageTitleStrings.MainWikiControllerDefault; + Description = NamedOverlayComponentStrings.WikiDescription; IconTexture = "Icons/Hexacons/wiki"; } } diff --git a/osu.Game/Overlays/Wiki/WikiMainPage.cs b/osu.Game/Overlays/Wiki/WikiMainPage.cs index c4c0b83ef4..c9ee2cbfd5 100644 --- a/osu.Game/Overlays/Wiki/WikiMainPage.cs +++ b/osu.Game/Overlays/Wiki/WikiMainPage.cs @@ -9,7 +9,7 @@ using osu.Framework.Graphics.Containers; using HtmlAgilityPack; using osu.Framework.Allocation; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Wiki { @@ -56,12 +56,12 @@ namespace osu.Game.Overlays.Wiki { Vertical = 30, }, - Child = new OsuSpriteText + Child = new OsuTextFlowContainer(t => t.Font = OsuFont.GetFont(Typeface.Inter, size: 12, weight: FontWeight.Light)) { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Text = blurbNode.InnerText, - Font = OsuFont.GetFont(size: 12), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + TextAnchor = Anchor.TopCentre, } }; } diff --git a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs index e1c00a955b..7e7e005586 100644 --- a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs +++ b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs @@ -89,7 +89,7 @@ namespace osu.Game.Overlays.Wiki DocumentMargin = new MarginPadding(0); } - public override SpriteText CreateSpriteText() => base.CreateSpriteText().With(t => t.Font = t.Font.With(weight: FontWeight.Bold)); + public override SpriteText CreateSpriteText() => base.CreateSpriteText().With(t => t.Font = t.Font.With(Typeface.Torus, weight: FontWeight.Bold)); public override MarkdownTextFlowContainer CreateTextFlow() => base.CreateTextFlow().With(f => f.TextAnchor = Anchor.TopCentre); diff --git a/osu.Game/Performance/HighPerformanceSession.cs b/osu.Game/Performance/HighPerformanceSession.cs index 661c1046f1..3ef0e0bf93 100644 --- a/osu.Game/Performance/HighPerformanceSession.cs +++ b/osu.Game/Performance/HighPerformanceSession.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Screens.Play; namespace osu.Game.Performance { @@ -12,9 +13,9 @@ namespace osu.Game.Performance private readonly IBindable localUserPlaying = new Bindable(); [BackgroundDependencyLoader] - private void load(OsuGame game) + private void load(ILocalUserPlayInfo localUserInfo) { - localUserPlaying.BindTo(game.LocalUserPlaying); + localUserPlaying.BindTo(localUserInfo.IsPlaying); } protected override void LoadComplete() 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/Configuration/RulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs index 0ff3455f00..a0ec8e3e0e 100644 --- a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs @@ -2,16 +2,86 @@ // 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.Configuration; using osu.Game.Configuration; +using osu.Game.Database; namespace osu.Game.Rulesets.Configuration { - public abstract class RulesetConfigManager : DatabasedConfigManager, IRulesetConfigManager + public abstract class RulesetConfigManager : ConfigManager, IRulesetConfigManager where TLookup : struct, Enum { - protected RulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null) - : base(settings, ruleset, variant) + private readonly RealmContextFactory realmFactory; + + private readonly int variant; + + private List databasedSettings = new List(); + + private readonly int rulesetId; + + protected RulesetConfigManager(SettingsStore store, RulesetInfo ruleset, int? variant = null) { + realmFactory = store?.Realm; + + if (realmFactory != null && !ruleset.ID.HasValue) + throw new InvalidOperationException("Attempted to add databased settings for a non-databased ruleset"); + + rulesetId = ruleset.ID ?? -1; + + this.variant = variant ?? 0; + + Load(); + + InitialiseDefaults(); + } + + protected override void PerformLoad() + { + if (realmFactory != null) + { + // As long as RulesetConfigCache exists, there is no need to subscribe to realm events. + databasedSettings = realmFactory.Context.All().Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList(); + } + } + + protected override bool PerformSave() + { + // do nothing, realm saves immediately + return true; + } + + protected override void AddBindable(TLookup lookup, Bindable bindable) + { + base.AddBindable(lookup, bindable); + + var setting = databasedSettings.Find(s => s.Key == lookup.ToString()); + + if (setting != null) + { + bindable.Parse(setting.Value); + } + else + { + setting = new RealmRulesetSetting + { + Key = lookup.ToString(), + Value = bindable.Value.ToString(), + RulesetID = rulesetId, + Variant = variant, + }; + + realmFactory?.Context.Write(() => realmFactory.Context.Add(setting)); + + databasedSettings.Add(setting); + } + + bindable.ValueChanged += b => + { + realmFactory?.Context.Write(() => setting.Value = b.NewValue.ToString()); + }; } } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 3cc69bd85b..200bbf3f92 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -7,6 +7,8 @@ using System.Linq; using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; @@ -16,6 +18,14 @@ namespace osu.Game.Rulesets.Difficulty { public abstract class DifficultyCalculator { + /// + /// The beatmap for which difficulty will be calculated. + /// + protected IBeatmap Beatmap { get; private set; } + + private Mod[] playableMods; + private double clockRate; + private readonly Ruleset ruleset; private readonly WorkingBeatmap beatmap; @@ -32,14 +42,45 @@ 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(); + preProcess(mods); - IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods); + var skills = CreateSkills(Beatmap, playableMods, clockRate); - var track = new TrackVirtual(10000); - mods.OfType().ForEach(m => m.ApplyToTrack(track)); + if (!Beatmap.HitObjects.Any()) + return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); - return calculate(playableBeatmap, mods, track.Rate); + foreach (var hitObject in getDifficultyHitObjects()) + { + foreach (var skill in skills) + skill.ProcessInternal(hitObject); + } + + return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); + } + + public List CalculateTimed(params Mod[] mods) + { + preProcess(mods); + + var attribs = new List(); + + if (!Beatmap.HitObjects.Any()) + return attribs; + + var skills = CreateSkills(Beatmap, playableMods, clockRate); + var progressiveBeatmap = new ProgressiveCalculationBeatmap(Beatmap); + + foreach (var hitObject in getDifficultyHitObjects()) + { + progressiveBeatmap.HitObjects.Add(hitObject.BaseObject); + + foreach (var skill in skills) + skill.ProcessInternal(hitObject); + + attribs.Add(new TimedDifficultyAttributes(hitObject.EndTime, CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); + } + + return attribs; } /// @@ -57,24 +98,24 @@ namespace osu.Game.Rulesets.Difficulty } } - private DifficultyAttributes calculate(IBeatmap beatmap, Mod[] mods, double clockRate) + /// + /// Retrieves the s to calculate against. + /// + private IEnumerable getDifficultyHitObjects() => SortObjects(CreateDifficultyHitObjects(Beatmap, clockRate)); + + /// + /// Performs required tasks before every calculation. + /// + /// The original list of s. + private void preProcess(Mod[] mods) { - var skills = CreateSkills(beatmap, mods, clockRate); + playableMods = mods.Select(m => m.DeepClone()).ToArray(); - if (!beatmap.HitObjects.Any()) - return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); + Beatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, playableMods); - var difficultyHitObjects = SortObjects(CreateDifficultyHitObjects(beatmap, clockRate)).ToList(); - - foreach (var hitObject in difficultyHitObjects) - { - foreach (var skill in skills) - { - skill.ProcessInternal(hitObject); - } - } - - return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); + var track = new TrackVirtual(10000); + playableMods.OfType().ForEach(m => m.ApplyToTrack(track)); + clockRate = track.Rate; } /// @@ -86,7 +127,7 @@ namespace osu.Game.Rulesets.Difficulty => input.OrderBy(h => h.BaseObject.StartTime); /// - /// Creates all combinations which adjust the difficulty. + /// Creates all combinations which adjust the difficulty. /// public Mod[] CreateDifficultyAdjustmentModCombinations() { @@ -154,14 +195,15 @@ namespace osu.Game.Rulesets.Difficulty } /// - /// Retrieves all s which adjust the difficulty. + /// Retrieves all s which adjust the difficulty. /// protected virtual Mod[] DifficultyAdjustmentMods => Array.Empty(); /// /// Creates to describe beatmap's calculated difficulty. /// - /// The whose difficulty was calculated. + /// The whose difficulty was calculated. + /// This may differ from in the case of timed calculation. /// The s that difficulty was calculated with. /// The skills which processed the beatmap. /// The rate at which the gameplay clock is run at. @@ -178,10 +220,58 @@ namespace osu.Game.Rulesets.Difficulty /// /// Creates the s to calculate the difficulty of an . /// - /// The whose difficulty will be calculated. + /// The whose difficulty will be calculated. + /// This may differ from in the case of timed calculation. /// Mods to calculate difficulty with. /// Clockrate to calculate difficulty with. /// The s. protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate); + + /// + /// Used to calculate timed difficulty attributes, where only a subset of hitobjects should be visible at any point in time. + /// + private class ProgressiveCalculationBeatmap : IBeatmap + { + private readonly IBeatmap baseBeatmap; + + public ProgressiveCalculationBeatmap(IBeatmap baseBeatmap) + { + this.baseBeatmap = baseBeatmap; + } + + public readonly List HitObjects = new List(); + + IReadOnlyList IBeatmap.HitObjects => HitObjects; + + #region Delegated IBeatmap implementation + + public BeatmapInfo BeatmapInfo + { + get => baseBeatmap.BeatmapInfo; + set => baseBeatmap.BeatmapInfo = value; + } + + public ControlPointInfo ControlPointInfo + { + get => baseBeatmap.ControlPointInfo; + set => baseBeatmap.ControlPointInfo = value; + } + + public BeatmapMetadata Metadata => baseBeatmap.Metadata; + + public BeatmapDifficulty Difficulty + { + get => baseBeatmap.Difficulty; + set => baseBeatmap.Difficulty = value; + } + + public List Breaks => baseBeatmap.Breaks; + public double TotalBreakTime => baseBeatmap.TotalBreakTime; + public IEnumerable GetStatistics() => baseBeatmap.GetStatistics(); + public double GetMostCommonBeatLength() => baseBeatmap.GetMostCommonBeatLength(); + public IBeatmap Clone() => new ProgressiveCalculationBeatmap(baseBeatmap.Clone()); + + #endregion + } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs new file mode 100644 index 0000000000..d8babf2f32 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.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; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + /// + /// Used to processes strain values of s, keep track of strain levels caused by the processed objects + /// and to calculate a final difficulty value representing the difficulty of hitting all the processed objects. + /// + public abstract class StrainDecaySkill : StrainSkill + { + /// + /// Strain values are multiplied by this number for the given skill. Used to balance the value of different skills between each other. + /// + protected abstract double SkillMultiplier { get; } + + /// + /// Determines how quickly strain decays for the given skill. + /// For example a value of 0.15 indicates that strain decays to 15% of its original value in one second. + /// + protected abstract double StrainDecayBase { get; } + + /// + /// The current strain level. + /// + protected double CurrentStrain { get; private set; } + + protected StrainDecaySkill(Mod[] mods) + : base(mods) + { + } + + protected override double CalculateInitialStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].StartTime); + + protected override double StrainValueAt(DifficultyHitObject current) + { + CurrentStrain *= strainDecay(current.DeltaTime); + CurrentStrain += StrainValueOf(current) * SkillMultiplier; + + return CurrentStrain; + } + + /// + /// Calculates the strain value of a . This value is affected by previously processed objects. + /// + protected abstract double StrainValueOf(DifficultyHitObject current); + + private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); + } +} diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 71cee36812..bbd2f079aa 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -15,33 +15,17 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// public abstract class StrainSkill : Skill { - /// - /// Strain values are multiplied by this number for the given skill. Used to balance the value of different skills between each other. - /// - protected abstract double SkillMultiplier { get; } - - /// - /// Determines how quickly strain decays for the given skill. - /// For example a value of 0.15 indicates that strain decays to 15% of its original value in one second. - /// - protected abstract double StrainDecayBase { get; } - /// /// The weight by which each strain value decays. /// protected virtual double DecayWeight => 0.9; - /// - /// The current strain level. - /// - protected double CurrentStrain { get; private set; } = 1; - /// /// The length of each strain section. /// protected virtual int SectionLength => 400; - private double currentSectionPeak = 1; // We also keep track of the peak strain level in the current section. + private double currentSectionPeak; // We also keep track of the peak strain level in the current section. private double currentSectionEnd; @@ -52,6 +36,11 @@ namespace osu.Game.Rulesets.Difficulty.Skills { } + /// + /// Returns the strain value at . This value is calculated with or without respect to previous objects. + /// + protected abstract double StrainValueAt(DifficultyHitObject current); + /// /// Process a and update current strain values accordingly. /// @@ -68,10 +57,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills currentSectionEnd += SectionLength; } - CurrentStrain *= strainDecay(current.DeltaTime); - CurrentStrain += StrainValueOf(current) * SkillMultiplier; - - currentSectionPeak = Math.Max(CurrentStrain, currentSectionPeak); + currentSectionPeak = Math.Max(StrainValueAt(current), currentSectionPeak); } /// @@ -88,9 +74,9 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// The beginning of the new section in milliseconds. private void startNewSectionFrom(double time) { - // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries. + // The maximum strain of the new section is not zero by default // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. - currentSectionPeak = GetPeakStrain(time); + currentSectionPeak = CalculateInitialStrain(time); } /// @@ -98,7 +84,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// /// The time to retrieve the peak strain at. /// The peak strain. - protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].StartTime); + protected abstract double CalculateInitialStrain(double time); /// /// Returns a live enumerable of the peak strains for each section of the beatmap, @@ -109,7 +95,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// - public sealed override double DifficultyValue() + public override double DifficultyValue() { double difficulty = 0; double weight = 1; @@ -124,12 +110,5 @@ namespace osu.Game.Rulesets.Difficulty.Skills return difficulty; } - - /// - /// Calculates the strain value of a . This value is affected by previously processed objects. - /// - protected abstract double StrainValueOf(DifficultyHitObject current); - - private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); } } diff --git a/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs new file mode 100644 index 0000000000..973b2dacb2 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.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; + +namespace osu.Game.Rulesets.Difficulty +{ + /// + /// Wraps a object and adds a time value for which the attribute is valid. + /// Output by . + /// + public class TimedDifficultyAttributes : IComparable + { + public readonly double Time; + public readonly DifficultyAttributes Attributes; + + public TimedDifficultyAttributes(double time, DifficultyAttributes attributes) + { + Time = time; + Attributes = attributes; + } + + public int CompareTo(TimedDifficultyAttributes other) => Time.CompareTo(other.Time); + } +} diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index 12e8d2e36f..6ed91e983a 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -32,7 +32,8 @@ namespace osu.Game.Rulesets.Edit // Compose new CheckUnsnappedObjects(), - new CheckConcurrentObjects() + new CheckConcurrentObjects(), + new CheckZeroLengthObjects(), }; public IEnumerable Run(BeatmapVerifierContext context) 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/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index a7005954b2..b41e0442bc 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; @@ -63,10 +64,12 @@ 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; @@ -126,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)") { @@ -160,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; @@ -219,7 +230,8 @@ namespace osu.Game.Rulesets.Edit if (item != null) { - item.Select(); + if (!item.Selected.Disabled) + item.Select(); return true; } } @@ -380,7 +392,7 @@ namespace osu.Game.Rulesets.Edit public override float GetBeatSnapDistanceAt(double referenceTime) { DifficultyControlPoint difficultyPoint = EditorBeatmap.ControlPointInfo.DifficultyPointAt(referenceTime); - return (float)(100 * EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier / BeatSnapProvider.BeatDivisor); + return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier / BeatSnapProvider.BeatDivisor); } public override float DurationToDistance(double referenceTime, double duration) 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/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 82e90399c9..0c0c5990d1 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -109,10 +109,10 @@ namespace osu.Game.Rulesets.Edit } /// - /// Invokes , + /// Invokes , /// refreshing and parameters for the . /// - protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty); + protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false; diff --git a/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs b/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs index 13cc41f8e0..dd2ad2cbfa 100644 --- a/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs +++ b/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs @@ -14,15 +14,15 @@ namespace osu.Game.Rulesets.Filter public interface IRulesetFilterCriteria { /// - /// Checks whether the supplied satisfies ruleset-specific custom criteria, + /// Checks whether the supplied satisfies ruleset-specific custom criteria, /// in addition to the ones mandated by song select. /// - /// The beatmap to test the criteria against. + /// The beatmap to test the criteria against. /// /// true if the beatmap matches the ruleset-specific custom filtering criteria, /// false otherwise. /// - bool Matches(BeatmapInfo beatmap); + bool Matches(BeatmapInfo beatmapInfo); /// /// Attempts to parse a single custom keyword criterion, given by the user via the song select search box. diff --git a/osu.Game/Rulesets/IRulesetInfo.cs b/osu.Game/Rulesets/IRulesetInfo.cs new file mode 100644 index 0000000000..779433dc81 --- /dev/null +++ b/osu.Game/Rulesets/IRulesetInfo.cs @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Database; + +#nullable enable + +namespace osu.Game.Rulesets +{ + /// + /// A representation of a ruleset's metadata. + /// + public interface IRulesetInfo : IHasOnlineID + { + /// + /// The user-exposed name of this ruleset. + /// + string Name { get; } + + /// + /// An acronym defined by the ruleset that can be used as a permanent identifier. + /// + string ShortName { get; } + + /// + /// A string representation of this ruleset, to be used with reflection to instantiate the ruleset represented by this metadata. + /// + string InstantiationInfo { get; } + + Ruleset? CreateInstance() + { + var type = Type.GetType(InstantiationInfo); + + if (type == null) + return null; + + var ruleset = Activator.CreateInstance(type) as Ruleset; + + // overwrite the pre-populated RulesetInfo with a potentially database attached copy. + // TODO: figure if we still want/need this after switching to realm. + // ruleset.RulesetInfo = this; + + return ruleset; + } + } +} diff --git a/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs index 21ac017685..29b771a81d 100644 --- a/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs +++ b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs @@ -47,6 +47,13 @@ namespace osu.Game.Rulesets.Judgements }; } + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// public virtual void PlayAnimation() { switch (Result) @@ -60,12 +67,6 @@ namespace osu.Game.Rulesets.Judgements this.RotateTo(0); this.RotateTo(40, 800, Easing.InQuint); - - break; - - default: - this.ScaleTo(0.9f); - this.ScaleTo(1, 500, Easing.OutElastic); break; } diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index 8a57b4af91..d25d46c6e2 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.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.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -31,6 +32,9 @@ namespace osu.Game.Rulesets.Judgements private readonly Container aboveHitObjectsContent; + private readonly Lazy proxiedAboveHitObjectsContent; + public Drawable ProxiedAboveHitObjectsContent => proxiedAboveHitObjectsContent.Value; + /// /// Creates a drawable which visualises a . /// @@ -52,6 +56,8 @@ namespace osu.Game.Rulesets.Judgements Depth = float.MinValue, RelativeSizeAxes = Axes.Both }); + + proxiedAboveHitObjectsContent = new Lazy(() => aboveHitObjectsContent.CreateProxy()); } [BackgroundDependencyLoader] @@ -60,8 +66,6 @@ namespace osu.Game.Rulesets.Judgements prepareDrawables(); } - public Drawable GetProxyAboveHitObjectsContent() => aboveHitObjectsContent.CreateProxy(); - /// /// Apply top-level animations to the current judgement when successfully hit. /// If displaying components which require lifetime extensions, manually adjusting is required. @@ -117,7 +121,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/Mods/DifficultyAdjustSettingsControl.cs b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs new file mode 100644 index 0000000000..67b24d24d0 --- /dev/null +++ b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs @@ -0,0 +1,144 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using 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. + private readonly DifficultyBindableWithCurrent current = new DifficultyBindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + public SliderControl(BindableNumber currentNumber) + { + InternalChildren = new Drawable[] + { + new SettingsSlider + { + ShowsDefaultIndicator = false, + Current = currentNumber, + } + }; + + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + } + } + + private class DifficultyBindableWithCurrent : DifficultyBindable, IHasCurrentValue + { + private Bindable currentBound; + + public Bindable Current + { + get => this; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + if (currentBound != null) UnbindFrom(currentBound); + BindTo(currentBound = value); + } + } + + public DifficultyBindableWithCurrent(float? defaultValue = default) + : base(defaultValue) + { + } + + protected override Bindable CreateInstance() => new DifficultyBindableWithCurrent(); + } + } +} diff --git a/osu.Game/Rulesets/Mods/DifficultyBindable.cs b/osu.Game/Rulesets/Mods/DifficultyBindable.cs new file mode 100644 index 0000000000..6cfae0b085 --- /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); + } + + protected override Bindable CreateInstance() => new DifficultyBindable(); + } +} 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/IApplicableToHealthProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs index a181955653..2676060efa 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Mods public interface IApplicableToHealthProcessor : IApplicableMod { /// - /// Provide a to a mod. Called once on initialisation of a play instance. + /// Provides a loaded to a mod. Called once on initialisation of a play instance. /// void ApplyToHealthProcessor(HealthProcessor healthProcessor); } diff --git a/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs index cb00770868..b93e50921f 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Mods public interface IApplicableToScoreProcessor : IApplicableMod { /// - /// Provide a to a mod. Called once on initialisation of a play instance. + /// Provides a loaded to a mod. Called once on initialisation of a play instance. /// void ApplyToScoreProcessor(ScoreProcessor scoreProcessor); diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index a5e19f293c..d5d1de91de 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using Newtonsoft.Json; +using osu.Framework.Graphics.Sprites; namespace osu.Game.Rulesets.Mods { @@ -11,7 +11,37 @@ namespace osu.Game.Rulesets.Mods /// /// The shortened name of this mod. /// - [JsonProperty("acronym")] string Acronym { get; } + + /// + /// The name of this mod. + /// + string Name { get; } + + /// + /// The user readable description of this mod. + /// + string Description { get; } + + /// + /// The type of this mod. + /// + ModType Type { get; } + + /// + /// The icon of this mod. + /// + IconUsage? Icon { get; } + + /// + /// 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 multiplayer selection, for example). + /// + bool UserPlayable { get; } + + /// + /// Create a fresh instance based on this mod. + /// + Mod CreateInstance() => (Mod)Activator.CreateInstance(GetType()); } } diff --git a/osu.Game/Rulesets/Mods/Metronome.cs b/osu.Game/Rulesets/Mods/Metronome.cs new file mode 100644 index 0000000000..8b6d86c45f --- /dev/null +++ b/osu.Game/Rulesets/Mods/Metronome.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.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mods +{ + public class Metronome : BeatSyncedContainer, IAdjustableAudioComponent + { + private readonly double firstHitTime; + + private readonly PausableSkinnableSound sample; + + /// Start time of the first hit object, used for providing a count down. + public Metronome(double firstHitTime) + { + this.firstHitTime = firstHitTime; + AllowMistimedEventFiring = false; + Divisor = 1; + + InternalChild = 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(); + } + + #region IAdjustableAudioComponent + + public IBindable AggregateVolume => sample.AggregateVolume; + + public IBindable AggregateBalance => sample.AggregateBalance; + + public IBindable AggregateFrequency => sample.AggregateFrequency; + + public IBindable AggregateTempo => sample.AggregateTempo; + + public BindableNumber Volume => sample.Volume; + + public BindableNumber Balance => sample.Balance; + + public BindableNumber Frequency => sample.Frequency; + + public BindableNumber Tempo => sample.Tempo; + + public void BindAdjustments(IAggregateAudioAdjustment component) + { + sample.BindAdjustments(component); + } + + public void UnbindAdjustments(IAggregateAudioAdjustment component) + { + sample.UnbindAdjustments(component); + } + + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) + { + sample.AddAdjustment(type, adjustBindable); + } + + public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) + { + sample.RemoveAdjustment(type, adjustBindable); + } + + public void RemoveAllAdjustments(AdjustableProperty type) + { + sample.RemoveAllAdjustments(type); + } + + #endregion + } +} diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 6f00bb6c75..7136795461 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -11,7 +11,6 @@ using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Game.Configuration; -using osu.Game.IO.Serialization; using osu.Game.Rulesets.UI; using osu.Game.Utils; @@ -21,34 +20,19 @@ 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, IDeepCloneable { - /// - /// The name of this mod. - /// [JsonIgnore] public abstract string Name { get; } - /// - /// The shortened name of this mod. - /// public abstract string Acronym { get; } - /// - /// The icon of this mod. - /// [JsonIgnore] public virtual IconUsage? Icon => null; - /// - /// The type of this mod. - /// [JsonIgnore] public virtual ModType Type => ModType.Fun; - /// - /// The user readable description of this mod. - /// [JsonIgnore] public abstract string Description { get; } @@ -107,10 +91,6 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool HasImplementation => this is IApplicableMod; - /// - /// 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; @@ -129,10 +109,21 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual Type[] IncompatibleMods => Array.Empty(); + private IReadOnlyList settingsBacking; + + /// + /// A list of the all settings within this mod. + /// + internal IReadOnlyList Settings => + settingsBacking ??= this.GetSettingsSourceProperties() + .Select(p => p.Item2.GetValue(this)) + .Cast() + .ToList(); + /// /// 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); @@ -191,15 +182,39 @@ namespace osu.Game.Rulesets.Mods if (ReferenceEquals(this, other)) return true; return GetType() == other.GetType() && - this.GetSettingsSourceProperties().All(pair => - EqualityComparer.Default.Equals( - ModUtils.GetSettingUnderlyingValue(pair.Item2.GetValue(this)), - ModUtils.GetSettingUnderlyingValue(pair.Item2.GetValue(other)))); + Settings.SequenceEqual(other.Settings, ModSettingsEqualityComparer.Default); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + hashCode.Add(GetType()); + + foreach (var setting in Settings) + hashCode.Add(ModUtils.GetSettingUnderlyingValue(setting)); + + return hashCode.ToHashCode(); } /// /// Reset all custom settings for this mod back to their defaults. /// public virtual void ResetSettingsToDefaults() => CopyFrom((Mod)Activator.CreateInstance(GetType())); + + private class ModSettingsEqualityComparer : IEqualityComparer + { + public static ModSettingsEqualityComparer Default { get; } = new ModSettingsEqualityComparer(); + + public bool Equals(IBindable x, IBindable y) + { + object xValue = x == null ? null : ModUtils.GetSettingUnderlyingValue(x); + object yValue = y == null ? null : ModUtils.GetSettingUnderlyingValue(y); + + return EqualityComparer.Default.Equals(xValue, yValue); + } + + public int GetHashCode(IBindable obj) => ModUtils.GetSettingUnderlyingValue(obj).GetHashCode(); + } } } 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/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index b70eee4e1d..eefa1531c4 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) + public void ReadFromDifficulty(IBeatmapDifficultyInfo 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/ModFailCondition.cs b/osu.Game/Rulesets/Mods/ModFailCondition.cs index c0d7bae2b2..4425ece513 100644 --- a/osu.Game/Rulesets/Mods/ModFailCondition.cs +++ b/osu.Game/Rulesets/Mods/ModFailCondition.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; +using osu.Game.Configuration; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -11,9 +13,12 @@ namespace osu.Game.Rulesets.Mods { public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModRelax), typeof(ModAutoplay) }; + [SettingSource("Restart on fail", "Automatically restarts when failed.")] + public BindableBool Restart { get; } = new BindableBool(); + public virtual bool PerformFail() => true; - public virtual bool RestartOnFail => true; + public virtual bool RestartOnFail => Restart.Value; public void ApplyToHealthProcessor(HealthProcessor healthProcessor) { diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 7abae71273..a77a83b36c 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -69,9 +69,11 @@ namespace osu.Game.Rulesets.Mods public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { var flashlight = CreateFlashlight(); - flashlight.Combo = Combo; + flashlight.RelativeSizeAxes = Axes.Both; flashlight.Colour = Color4.Black; + + flashlight.Combo.BindTo(Combo); drawableRuleset.KeyBindingInputManager.Add(flashlight); flashlight.Breaks = drawableRuleset.Beatmap.Breaks; @@ -81,7 +83,8 @@ namespace osu.Game.Rulesets.Mods public abstract class Flashlight : Drawable { - internal BindableInt Combo; + public readonly BindableInt Combo = new BindableInt(); + private IShader shader; protected override DrawNode CreateDrawNode() => new FlashlightDrawNode(this); diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 4edcb0b074..da838f9ea6 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Mods public override string Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; - public void ReadFromDifficulty(BeatmapDifficulty difficulty) + public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) { } diff --git a/osu.Game/Rulesets/Mods/ModMirror.cs b/osu.Game/Rulesets/Mods/ModMirror.cs new file mode 100644 index 0000000000..3c4b7d0c60 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModMirror.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.Mods +{ + public abstract class ModMirror : Mod + { + public override string Name => "Mirror"; + public override string Acronym => "MR"; + public override ModType Type => ModType.Conversion; + public override double ScoreMultiplier => 1; + } +} diff --git a/osu.Game/Rulesets/Mods/ModMuted.cs b/osu.Game/Rulesets/Mods/ModMuted.cs new file mode 100644 index 0000000000..1d33b44812 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModMuted.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModMuted : Mod + { + public override string Name => "Muted"; + public override string Acronym => "MU"; + public override IconUsage? Icon => FontAwesome.Solid.VolumeMute; + public override string Description => "Can you still feel the rhythm without music?"; + public override ModType Type => ModType.Fun; + public override double ScoreMultiplier => 1; + } + + public abstract class ModMuted : ModMuted, IApplicableToDrawableRuleset, IApplicableToTrack, IApplicableToScoreProcessor + where TObject : HitObject + { + private readonly BindableNumber mainVolumeAdjust = new BindableDouble(0.5); + private readonly BindableNumber metronomeVolumeAdjust = new BindableDouble(0.5); + + private BindableNumber currentCombo; + + [SettingSource("Enable metronome", "Add a metronome beat to help you keep track of the rhythm.")] + public BindableBool EnableMetronome { get; } = new BindableBool + { + Default = true, + Value = true + }; + + [SettingSource("Final volume at combo", "The combo count at which point the track reaches its final volume.", SettingControlType = typeof(SettingsSlider))] + public BindableInt MuteComboCount { get; } = new BindableInt + { + Default = 100, + Value = 100, + MinValue = 0, + MaxValue = 500, + }; + + [SettingSource("Start muted", "Increase volume as combo builds.")] + public BindableBool InverseMuting { get; } = new BindableBool + { + Default = false, + Value = false + }; + + [SettingSource("Mute hit sounds", "Hit sounds are also muted alongside the track.")] + public BindableBool AffectsHitSounds { get; } = new BindableBool + { + Default = true, + Value = true + }; + + protected ModMuted() + { + InverseMuting.BindValueChanged(i => MuteComboCount.MinValue = i.NewValue ? 1 : 0, true); + } + + public void ApplyToTrack(ITrack track) + { + track.AddAdjustment(AdjustableProperty.Volume, mainVolumeAdjust); + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + if (EnableMetronome.Value) + { + Metronome metronome; + + drawableRuleset.Overlays.Add(metronome = new Metronome(drawableRuleset.Beatmap.HitObjects.First().StartTime)); + + metronome.AddAdjustment(AdjustableProperty.Volume, metronomeVolumeAdjust); + } + + if (AffectsHitSounds.Value) + drawableRuleset.Audio.AddAdjustment(AdjustableProperty.Volume, mainVolumeAdjust); + } + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + currentCombo = scoreProcessor.Combo.GetBoundCopy(); + currentCombo.BindValueChanged(combo => + { + double dimFactor = MuteComboCount.Value == 0 ? 1 : (double)combo.NewValue / MuteComboCount.Value; + + if (InverseMuting.Value) + dimFactor = 1 - dimFactor; + + scoreProcessor.TransformBindableTo(metronomeVolumeAdjust, dimFactor, 500, Easing.OutQuint); + scoreProcessor.TransformBindableTo(mainVolumeAdjust, 1 - dimFactor, 500, Easing.OutQuint); + }, true); + } + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + } + + public class MuteComboSlider : OsuSliderBar + { + public override LocalisableString TooltipText => Current.Value == 0 ? "always muted" : base.TooltipText; + } +} diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index 187a4d8e23..9016a24f8d 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -21,6 +21,11 @@ namespace osu.Game.Rulesets.Mods public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModSuddenDeath)).ToArray(); + protected ModPerfect() + { + Restart.Value = Restart.Default = true; + } + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => result.Type.AffectsAccuracy() && result.Type != result.Judgement.MaxResult; 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 a0717ec38e..29d8a475ef 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -124,7 +124,9 @@ namespace osu.Game.Rulesets.Objects.Drawables public readonly Bindable StartTimeBindable = new Bindable(); private readonly BindableList samplesBindable = new BindableList(); private readonly Bindable userPositionalHitSounds = new Bindable(); + private readonly Bindable comboIndexBindable = new Bindable(); + private readonly Bindable comboIndexWithOffsetsBindable = new Bindable(); protected override bool RequiresChildrenUpdate => true; @@ -185,9 +187,11 @@ namespace osu.Game.Rulesets.Objects.Drawables { base.LoadComplete(); - comboIndexBindable.BindValueChanged(_ => UpdateComboColour(), true); + comboIndexBindable.BindValueChanged(_ => UpdateComboColour()); + comboIndexWithOffsetsBindable.BindValueChanged(_ => UpdateComboColour(), true); - updateState(ArmedState.Idle, true); + // Apply transforms + updateState(State.Value, true); } /// @@ -250,7 +254,10 @@ namespace osu.Game.Rulesets.Objects.Drawables StartTimeBindable.BindValueChanged(onStartTimeChanged); if (HitObject is IHasComboInformation combo) + { comboIndexBindable.BindTo(combo.ComboIndexBindable); + comboIndexWithOffsetsBindable.BindTo(combo.ComboIndexWithOffsetsBindable); + } samplesBindable.BindTo(HitObject.SamplesBindable); samplesBindable.BindCollectionChanged(onSamplesChanged, true); @@ -275,8 +282,13 @@ namespace osu.Game.Rulesets.Objects.Drawables protected sealed override void OnFree(HitObjectLifetimeEntry entry) { StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable); + if (HitObject is IHasComboInformation combo) + { comboIndexBindable.UnbindFrom(combo.ComboIndexBindable); + comboIndexWithOffsetsBindable.UnbindFrom(combo.ComboIndexWithOffsetsBindable); + } + samplesBindable.UnbindFrom(HitObject.SamplesBindable); // Changes in start time trigger state updates. When a new hitobject is applied, OnApply() automatically performs a state update anyway. @@ -404,13 +416,13 @@ namespace osu.Game.Rulesets.Objects.Drawables clearExistingStateTransforms(); - using (BeginAbsoluteSequence(transformTime, true)) + using (BeginAbsoluteSequence(transformTime)) UpdateInitialTransforms(); - using (BeginAbsoluteSequence(StateUpdateTime, true)) + using (BeginAbsoluteSequence(StateUpdateTime)) UpdateStartTimeStateTransforms(); - using (BeginAbsoluteSequence(HitStateUpdateTime, true)) + using (BeginAbsoluteSequence(HitStateUpdateTime)) UpdateHitStateTransforms(newState); state.Value = newState; @@ -502,8 +514,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); + AccentColour.Value = combo.GetComboColour(CurrentSkin); } /// diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index db02eafa92..0b159819d4 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -3,14 +3,16 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; +using osu.Framework.Extensions.ListExtensions; +using osu.Framework.Lists; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; @@ -65,7 +67,6 @@ namespace osu.Game.Rulesets.Objects } } - [JsonIgnore] public SampleControlPoint SampleControlPoint; /// @@ -83,7 +84,7 @@ namespace osu.Game.Rulesets.Objects private readonly List nestedHitObjects = new List(); [JsonIgnore] - public IReadOnlyList NestedHitObjects => nestedHitObjects; + public SlimReadOnlyListWrapper NestedHitObjects => nestedHitObjects.AsSlimReadOnly(); public HitObject() { @@ -91,7 +92,7 @@ namespace osu.Game.Rulesets.Objects { double offset = time.NewValue - time.OldValue; - foreach (var nested in NestedHitObjects) + foreach (var nested in nestedHitObjects) nested.StartTime += offset; }; } @@ -102,12 +103,19 @@ namespace osu.Game.Rulesets.Objects /// The control points. /// The difficulty settings to use. /// The cancellation token. - public void ApplyDefaults(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty, CancellationToken cancellationToken = default) + public void ApplyDefaults(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty, CancellationToken cancellationToken = default) { ApplyDefaultsToSelf(controlPointInfo, difficulty); - // This is done here since ApplyDefaultsToSelf may be used to determine the end time - SampleControlPoint = controlPointInfo.SamplePointAt(this.GetEndTime() + control_point_leniency); + if (controlPointInfo is LegacyControlPointInfo legacyInfo) + { + // This is done here since ApplyDefaultsToSelf may be used to determine the end time + SampleControlPoint = legacyInfo.SamplePointAt(this.GetEndTime() + control_point_leniency); + } + else + { + SampleControlPoint ??= SampleControlPoint.DEFAULT; + } nestedHitObjects.Clear(); @@ -115,10 +123,14 @@ namespace osu.Game.Rulesets.Objects if (this is IHasComboInformation hasCombo) { - foreach (var n in NestedHitObjects.OfType()) + foreach (HitObject hitObject in nestedHitObjects) { - n.ComboIndexBindable.BindTo(hasCombo.ComboIndexBindable); - n.IndexInCurrentComboBindable.BindTo(hasCombo.IndexInCurrentComboBindable); + if (hitObject is IHasComboInformation n) + { + n.ComboIndexBindable.BindTo(hasCombo.ComboIndexBindable); + n.ComboIndexWithOffsetsBindable.BindTo(hasCombo.ComboIndexWithOffsetsBindable); + n.IndexInCurrentComboBindable.BindTo(hasCombo.IndexInCurrentComboBindable); + } } } @@ -130,7 +142,7 @@ namespace osu.Game.Rulesets.Objects DefaultsApplied?.Invoke(this); } - protected virtual void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected virtual void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { Kiai = controlPointInfo.EffectPointAt(StartTime + control_point_leniency).KiaiMode; 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/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index e8a5463cce..0942a7264d 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -323,7 +323,7 @@ namespace osu.Game.Rulesets.Objects.Legacy } // The first control point must have a definite type. - vertices[0].Type.Value = type; + vertices[0].Type = type; // A path can have multiple implicit segments of the same type if there are two sequential control points with the same position. // To handle such cases, this code may return multiple path segments with the final control point in each segment having a non-null type. @@ -337,7 +337,7 @@ namespace osu.Game.Rulesets.Objects.Legacy while (++endIndex < vertices.Length - endPointLength) { // Keep incrementing while an implicit segment doesn't need to be started - if (vertices[endIndex].Position.Value != vertices[endIndex - 1].Position.Value) + if (vertices[endIndex].Position != vertices[endIndex - 1].Position) continue; // The last control point of each segment is not allowed to start a new implicit segment. @@ -345,7 +345,7 @@ namespace osu.Game.Rulesets.Objects.Legacy continue; // Force a type on the last point, and return the current control point set as a segment. - vertices[endIndex - 1].Type.Value = type; + vertices[endIndex - 1].Type = type; yield return vertices.AsMemory().Slice(startIndex, endIndex - startIndex); // Skip the current control point - as it's the same as the one that's just been returned. @@ -360,11 +360,11 @@ namespace osu.Game.Rulesets.Objects.Legacy string[] vertexSplit = value.Split(':'); Vector2 pos = new Vector2((int)Parsing.ParseDouble(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseDouble(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)) - startPos; - point = new PathControlPoint { Position = { Value = pos } }; + point = new PathControlPoint { Position = pos }; } - static bool isLinear(PathControlPoint[] p) => Precision.AlmostEquals(0, (p[1].Position.Value.Y - p[0].Position.Value.Y) * (p[2].Position.Value.X - p[0].Position.Value.X) - - (p[1].Position.Value.X - p[0].Position.Value.X) * (p[2].Position.Value.Y - p[0].Position.Value.Y)); + static bool isLinear(PathControlPoint[] p) => Precision.AlmostEquals(0, (p[1].Position.Y - p[0].Position.Y) * (p[2].Position.X - p[0].Position.X) + - (p[1].Position.X - p[0].Position.X) * (p[2].Position.Y - p[0].Position.Y)); } private PathControlPoint[] mergePointsLists(List> controlPointList) diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index df569b91c1..e1de82ade7 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Objects.Legacy public double Velocity = 1; - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game/Rulesets/Objects/PathControlPoint.cs b/osu.Game/Rulesets/Objects/PathControlPoint.cs index f11917f4f4..53eb430fa3 100644 --- a/osu.Game/Rulesets/Objects/PathControlPoint.cs +++ b/osu.Game/Rulesets/Objects/PathControlPoint.cs @@ -3,7 +3,6 @@ using System; using Newtonsoft.Json; -using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -11,31 +10,55 @@ namespace osu.Game.Rulesets.Objects { public class PathControlPoint : IEquatable { + private Vector2 position; + /// /// The position of this . /// [JsonProperty] - public readonly Bindable Position = new Bindable(); + public Vector2 Position + { + get => position; + set + { + if (value == position) + return; + + position = value; + Changed?.Invoke(); + } + } + + private PathType? type; /// /// The type of path segment starting at this . /// If null, this will be a part of the previous path segment. /// [JsonProperty] - public readonly Bindable Type = new Bindable(); + public PathType? Type + { + get => type; + set + { + if (value == type) + return; + + type = value; + Changed?.Invoke(); + } + } /// /// Invoked when any property of this is changed. /// - internal event Action Changed; + public event Action Changed; /// /// Creates a new . /// public PathControlPoint() { - Position.ValueChanged += _ => Changed?.Invoke(); - Type.ValueChanged += _ => Changed?.Invoke(); } /// @@ -46,10 +69,10 @@ namespace osu.Game.Rulesets.Objects public PathControlPoint(Vector2 position, PathType? type = null) : this() { - Position.Value = position; - Type.Value = type; + Position = position; + Type = type; } - public bool Equals(PathControlPoint other) => Position.Value == other?.Position.Value && Type.Value == other.Type.Value; + public bool Equals(PathControlPoint other) => Position == other?.Position && Type == other.Type; } } diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index 55ef0bc5f6..9cc215589b 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Objects foreach (PathControlPoint point in ControlPoints) { - if (point.Type.Value != null) + if (point.Type != null) { if (!found) pointsInCurrentSegment.Clear(); @@ -215,18 +215,18 @@ namespace osu.Game.Rulesets.Objects Vector2[] vertices = new Vector2[ControlPoints.Count]; for (int i = 0; i < ControlPoints.Count; i++) - vertices[i] = ControlPoints[i].Position.Value; + vertices[i] = ControlPoints[i].Position; int start = 0; for (int i = 0; i < ControlPoints.Count; i++) { - if (ControlPoints[i].Type.Value == null && i < ControlPoints.Count - 1) + if (ControlPoints[i].Type == null && i < ControlPoints.Count - 1) continue; // The current vertex ends the segment var segmentVertices = vertices.AsSpan().Slice(start, i - start + 1); - var segmentType = ControlPoints[start].Type.Value ?? PathType.Linear; + var segmentType = ControlPoints[start].Type ?? PathType.Linear; foreach (Vector2 t in calculateSubPath(segmentVertices, segmentType)) { diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs new file mode 100644 index 0000000000..663746bfca --- /dev/null +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +#nullable enable + +namespace osu.Game.Rulesets.Objects +{ + public static class SliderPathExtensions + { + /// + /// Reverse the direction of this path. + /// + /// The . + /// The positional offset of the resulting path. It should be added to the start position of this path. + public static void Reverse(this SliderPath sliderPath, out Vector2 positionalOffset) + { + var points = sliderPath.ControlPoints.ToArray(); + positionalOffset = points.Last().Position; + + sliderPath.ControlPoints.Clear(); + + PathType? lastType = null; + + for (var i = 0; i < points.Length; i++) + { + var p = points[i]; + p.Position -= positionalOffset; + + // propagate types forwards to last null type + if (i == points.Length - 1) + p.Type = lastType; + else if (p.Type != null) + (p.Type, lastType) = (lastType, p.Type); + + sliderPath.ControlPoints.Insert(0, p); + } + } + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 4f66802079..29a56fc625 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 @@ -16,17 +15,25 @@ namespace osu.Game.Rulesets.Objects.Types Bindable IndexInCurrentComboBindable { get; } /// - /// The offset of this hitobject in the current combo. + /// The index of this hitobject in the current combo. /// int IndexInCurrentCombo { get; set; } Bindable ComboIndexBindable { get; } /// - /// The offset of this combo in relation to the beatmap. + /// The index of this combo in relation to the beatmap. /// int ComboIndex { get; set; } + Bindable ComboIndexWithOffsetsBindable { get; } + + /// + /// The index of this combo in relation to the beatmap, with all aggregate s applied. + /// This should be used instead of only when retrieving combo colours from the beatmap's skin. + /// + int ComboIndexWithOffsets { get; set; } + /// /// Whether the HitObject starts a new combo. /// @@ -40,11 +47,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/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 9fdaca88fd..b0c3836774 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; @@ -28,6 +29,7 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Testing; using osu.Game.Extensions; using osu.Game.Rulesets.Filter; +using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets @@ -37,13 +39,58 @@ namespace osu.Game.Rulesets { public RulesetInfo RulesetInfo { get; internal set; } - public IEnumerable GetAllMods() => Enum.GetValues(typeof(ModType)).Cast() - // Confine all mods of each mod type into a single IEnumerable - .SelectMany(GetModsFor) - // Filter out all null mods - .Where(mod => mod != null) - // Resolve MultiMods as their .Mods property - .SelectMany(mod => (mod as MultiMod)?.Mods ?? new[] { mod }); + private static readonly ConcurrentDictionary mod_reference_cache = new ConcurrentDictionary(); + + /// + /// A queryable source containing all available mods. + /// Call for consumption purposes. + /// + public IEnumerable AllMods + { + get + { + if (!(RulesetInfo.ID is int id)) + return CreateAllMods(); + + if (!mod_reference_cache.TryGetValue(id, out var mods)) + mod_reference_cache[id] = mods = CreateAllMods().Cast().ToArray(); + + return mods; + } + } + + /// + /// Returns fresh instances of all mods. + /// + /// + /// This comes with considerable allocation overhead. If only accessing for reference purposes (ie. not changing bindables / settings) + /// use instead. + /// + public IEnumerable CreateAllMods() => Enum.GetValues(typeof(ModType)).Cast() + // Confine all mods of each mod type into a single IEnumerable + .SelectMany(GetModsFor) + // Filter out all null mods + .Where(mod => mod != null) + // Resolve MultiMods as their .Mods property + .SelectMany(mod => (mod as MultiMod)?.Mods ?? new[] { mod }); + + /// + /// Returns a fresh instance of the mod matching the specified acronym. + /// + /// The acronym to query for . + public Mod CreateModFromAcronym(string acronym) + { + return AllMods.FirstOrDefault(m => m.Acronym == acronym)?.CreateInstance(); + } + + /// + /// Returns a fresh instance of the mod matching the specified type. + /// + public T CreateMod() + where T : Mod + { + return AllMods.FirstOrDefault(m => m is T)?.CreateInstance() as T; + } public abstract IEnumerable GetModsFor(ModType type); @@ -125,7 +172,7 @@ namespace osu.Game.Rulesets } [CanBeNull] - public ModAutoplay GetAutoplayMod() => GetAllMods().OfType().FirstOrDefault(); + public ModAutoplay GetAutoplayMod() => CreateMod(); public virtual ISkin CreateLegacySkinProvider([NotNull] ISkin skin, IBeatmap beatmap) => null; @@ -224,9 +271,9 @@ namespace osu.Game.Rulesets public abstract string ShortName { get; } /// - /// The playing verb to be shown in the . + /// The playing verb to be shown in the activities. /// - public virtual string PlayingVerb => "Playing solo"; + public virtual string PlayingVerb => "Playing"; /// /// A list of available variant ids. @@ -315,5 +362,11 @@ namespace osu.Game.Rulesets /// [CanBeNull] public virtual IRulesetFilterCriteria CreateRulesetFilterCriteria() => null; + + /// + /// Can be overridden to add a ruleset-specific section to the editor beatmap setup screen. + /// + [CanBeNull] + public virtual RulesetSetupSection CreateEditorSetupSection() => null; } } diff --git a/osu.Game/Rulesets/RulesetConfigCache.cs b/osu.Game/Rulesets/RulesetConfigCache.cs index d42428638c..aeac052673 100644 --- a/osu.Game/Rulesets/RulesetConfigCache.cs +++ b/osu.Game/Rulesets/RulesetConfigCache.cs @@ -2,9 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Concurrent; +using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Rulesets.Configuration; namespace osu.Game.Rulesets @@ -15,12 +16,31 @@ namespace osu.Game.Rulesets /// public class RulesetConfigCache : Component { - private readonly ConcurrentDictionary configCache = new ConcurrentDictionary(); - private readonly SettingsStore settingsStore; + private readonly RealmContextFactory realmFactory; + private readonly RulesetStore rulesets; - public RulesetConfigCache(SettingsStore settingsStore) + private readonly Dictionary configCache = new Dictionary(); + + public RulesetConfigCache(RealmContextFactory realmFactory, RulesetStore rulesets) { - this.settingsStore = settingsStore; + this.realmFactory = realmFactory; + this.rulesets = rulesets; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var settingsStore = new SettingsStore(realmFactory); + + // let's keep things simple for now and just retrieve all the required configs at startup.. + foreach (var ruleset in rulesets.AvailableRulesets) + { + if (ruleset.ID == null) + continue; + + configCache[ruleset.ID.Value] = ruleset.CreateInstance().CreateConfig(settingsStore); + } } /// @@ -34,7 +54,12 @@ namespace osu.Game.Rulesets if (ruleset.RulesetInfo.ID == null) return null; - return configCache.GetOrAdd(ruleset.RulesetInfo.ID.Value, _ => ruleset.CreateConfig(settingsStore)); + if (!configCache.TryGetValue(ruleset.RulesetInfo.ID.Value, out var config)) + // any ruleset request which wasn't initialised on startup should not be stored to realm. + // this should only be used by tests. + return ruleset.CreateConfig(null); + + return config; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index 59ec9cdd7e..ca6a083a58 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -10,7 +10,7 @@ using osu.Framework.Testing; namespace osu.Game.Rulesets { [ExcludeFromDynamicCompile] - public class RulesetInfo : IEquatable + public class RulesetInfo : IEquatable, IRulesetInfo { public int? ID { get; set; } @@ -54,5 +54,11 @@ namespace osu.Game.Rulesets } public override string ToString() => Name ?? $"{Name} ({ShortName}) ID: {ID}"; + + #region Implementation of IHasOnlineID + + public int? OnlineID => ID; + + #endregion } } diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 1f12f3dfeb..a9e04a02b5 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -30,7 +30,12 @@ namespace osu.Game.Rulesets // On android in release configuration assemblies are loaded from the apk directly into memory. // We cannot read assemblies from cwd, so should check loaded assemblies instead. loadFromAppDomain(); - loadFromDisk(); + + // This null check prevents Android from attempting to load the rulesets from disk, + // as the underlying path "AppContext.BaseDirectory", despite being non-nullable, it returns null on android. + // See https://github.com/xamarin/xamarin-android/issues/3489. + if (RuntimeInfo.StartupDirectory != null) + loadFromDisk(); // the event handler contains code for resolving dependency on the game assembly for rulesets located outside the base game directory. // It needs to be attached to the assembly lookup event before the actual call to loadUserRulesets() else rulesets located out of the base game directory will fail diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs index cae41e22f4..dfeb6b4788 100644 --- a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Scoring .First() ))); - targetMinimumHealth = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, min_health_target, mid_health_target, max_health_target); + targetMinimumHealth = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, min_health_target, mid_health_target, max_health_target); // Add back a portion of the amount of HP to be drained, depending on the lenience requested. targetMinimumHealth += drainLenience * (1 - targetMinimumHealth); diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index eaa1f95744..5599ed96a3 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.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 System.ComponentModel; using System.Diagnostics; +using System.Linq; using osu.Framework.Utils; namespace osu.Game.Rulesets.Scoring @@ -171,6 +173,11 @@ namespace osu.Game.Rulesets.Scoring /// public static bool IsScorable(this HitResult result) => result >= HitResult.Miss && result < HitResult.IgnoreMiss; + /// + /// An array of all scorable s. + /// + public static readonly HitResult[] SCORABLE_TYPES = ((HitResult[])Enum.GetValues(typeof(HitResult))).Where(r => r.IsScorable()).ToArray(); + /// /// Whether a is valid within a given range. /// diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs index 410614de07..3ffd1eb66b 100644 --- a/osu.Game/Rulesets/Scoring/HitWindows.cs +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.Scoring { foreach (var range in GetRanges()) { - var value = BeatmapDifficulty.DifficultyRange(difficulty, (range.Min, range.Average, range.Max)); + var value = IBeatmapDifficultyInfo.DifficultyRange(difficulty, (range.Min, range.Average, range.Max)); switch (range.Result) { diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index 201a05e569..ed4a16f0e8 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -18,6 +18,11 @@ namespace osu.Game.Rulesets.Scoring /// public event Action NewJudgement; + /// + /// Invoked when a judgement is reverted, usually due to rewinding gameplay. + /// + public event Action JudgementReverted; + /// /// The maximum number of hits that can be judged. /// @@ -71,6 +76,8 @@ namespace osu.Game.Rulesets.Scoring JudgedHits--; RevertResultInternal(result); + + JudgementReverted?.Invoke(result); } /// diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index f32f70d4ba..c1234f8fb3 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; @@ -223,12 +222,13 @@ namespace osu.Game.Rulesets.Scoring case ScoringMode.Standardised: double accuracyScore = accuracyPortion * accuracyRatio; double comboScore = comboPortion * comboRatio; - return (max_score * (accuracyScore + comboScore) + getBonusScore(statistics)) * scoreMultiplier; case ScoringMode.Classic: - // should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1) - return getBonusScore(statistics) + (accuracyRatio * Math.Max(1, maxCombo) * 300) * (1 + Math.Max(0, (comboRatio * maxCombo) - 1) * scoreMultiplier / 25); + // This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring. + // The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes. + double scaledStandardised = GetScore(ScoringMode.Standardised, maxCombo, accuracyRatio, comboRatio, statistics) / max_score; + return Math.Pow(scaledStandardised * (maxCombo + 1), 2) * 18; } } @@ -272,8 +272,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 +291,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,9 +339,8 @@ 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())) + foreach (var result in HitResultExtensions.SCORABLE_TYPES) score.Statistics[result] = GetStatistic(result); score.HitEvents = hitEvents; diff --git a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs index 4b3c3f90f0..dcd2cc8b55 100644 --- a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs +++ b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs @@ -3,14 +3,13 @@ using System; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.IO.Serialization; namespace osu.Game.Rulesets.Timing { /// /// A control point which adds an aggregated multiplier based on the provided 's BeatLength and 's SpeedMultiplier. /// - public class MultiplierControlPoint : IJsonSerializable, IComparable + public class MultiplierControlPoint : IComparable { /// /// The time in milliseconds at which this starts. diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 8dcc1ca164..29559f5036 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -1,29 +1,30 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Cursor; using osu.Game.Input.Handlers; using osu.Game.Overlays; using osu.Game.Replays; using osu.Game.Rulesets.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -98,6 +99,14 @@ namespace osu.Game.Rulesets.UI private DrawableRulesetDependencies dependencies; + /// + /// Audio adjustments which are applied to the playfield. + /// + /// + /// Does not affect . + /// + public IAdjustableAudioComponent Audio { get; private set; } + /// /// Creates a ruleset visualisation for the provided ruleset and beatmap. /// @@ -155,23 +164,28 @@ namespace osu.Game.Rulesets.UI [BackgroundDependencyLoader] private void load(CancellationToken? cancellationToken) { - InternalChildren = new Drawable[] + AudioContainer audioContainer; + + InternalChild = frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) { - frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) + FrameStablePlayback = FrameStablePlayback, + Children = new Drawable[] { - FrameStablePlayback = FrameStablePlayback, - Children = new Drawable[] + FrameStableComponents, + audioContainer = new AudioContainer { - FrameStableComponents, - KeyBindingInputManager + RelativeSizeAxes = Axes.Both, + Child = KeyBindingInputManager .WithChild(CreatePlayfieldAdjustmentContainer() .WithChild(Playfield) ), - Overlays, - } - }, + }, + Overlays, + } }; + Audio = audioContainer; + if ((ResumeOverlay = CreateResumeOverlay()) != null) { AddInternal(CreateInputManager() @@ -489,15 +503,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; } } 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/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index e9865f6c8b..c0b339a231 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -55,7 +55,10 @@ namespace osu.Game.Rulesets.UI /// /// The current direction of playback to be exposed to frame stable children. /// - private int direction; + /// + /// Initially it is presumed that playback will proceed in the forward direction. + /// + private int direction = 1; [BackgroundDependencyLoader(true)] private void load(GameplayClock clock, ISamplePlaybackDisabler sampleDisabler) @@ -139,7 +142,9 @@ namespace osu.Game.Rulesets.UI state = PlaybackState.NotValid; } - if (state == PlaybackState.Valid) + // if the proposed time is the same as the current time, assume that the clock will continue progressing in the same direction as previously. + // this avoids spurious flips in direction from -1 to 1 during rewinds. + if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime) direction = proposedTime >= manualClock.CurrentTime ? 1 : -1; double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime); diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs new file mode 100644 index 0000000000..c18698f77e --- /dev/null +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.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.Linq; +using osu.Framework.Graphics.Containers; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.UI +{ + /// + /// A component which can trigger the most appropriate hit sound for a given point in time, based on the state of a + /// + public class GameplaySampleTriggerSource : CompositeDrawable + { + /// + /// The number of concurrent samples allowed to be played concurrently so that it feels better when spam-pressing a key. + /// + private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY; + + private readonly HitObjectContainer hitObjectContainer; + + private int nextHitSoundIndex; + + private readonly Container hitSounds; + + public GameplaySampleTriggerSource(HitObjectContainer hitObjectContainer) + { + this.hitObjectContainer = hitObjectContainer; + + InternalChild = hitSounds = new Container + { + Name = "concurrent sample pool", + ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new PausableSkinnableSound()) + }; + } + + private HitObjectLifetimeEntry fallbackObject; + + /// + /// Play the most appropriate hit sound for the current point in time. + /// + public virtual void Play() + { + var nextObject = GetMostValidObject(); + + if (nextObject == null) + return; + + var samples = nextObject.Samples + .Select(s => nextObject.SampleControlPoint.ApplyTo(s)) + .Cast() + .ToArray(); + + PlaySamples(samples); + } + + protected void PlaySamples(ISampleInfo[] samples) + { + var hitSound = getNextSample(); + hitSound.Samples = samples; + hitSound.Play(); + } + + protected HitObject GetMostValidObject() + { + // The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time. + var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true)?.HitObject; + + // In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play. + if (hitObject == null) + { + // This lookup can be skipped if the last entry is still valid (in the future and not yet hit). + if (fallbackObject == null || fallbackObject.Result?.HasResult == true) + { + // We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty). + // If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager. + fallbackObject = hitObjectContainer.Entries + .Where(e => e.Result?.HasResult != true) + .OrderBy(e => e.HitObject.StartTime) + .FirstOrDefault(); + + // In the case there are no unjudged objects, the last hit object should be used instead. + fallbackObject ??= hitObjectContainer.Entries.LastOrDefault(); + } + + hitObject = fallbackObject?.HitObject; + } + + return hitObject; + } + + private SkinnableSound getNextSample() + { + SkinnableSound hitSound = hitSounds[nextHitSoundIndex]; + + // round robin over available samples to allow for concurrent playback. + nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds; + + return hitSound; + } + } +} diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 725cfa9c26..79bada0490 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -30,12 +30,12 @@ namespace osu.Game.Rulesets.UI private const float size = 80; - public virtual LocalisableString TooltipText => showTooltip ? mod.IconTooltip : null; + public virtual LocalisableString TooltipText => showTooltip ? ((mod as Mod)?.IconTooltip ?? mod.Name) : null; - private Mod mod; + private IMod mod; private readonly bool showTooltip; - public Mod Mod + public IMod Mod { get => mod; set @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.UI /// /// The mod to be displayed /// Whether a tooltip describing the mod should display on hover. - public ModIcon(Mod mod, bool showTooltip = true) + public ModIcon(IMod mod, bool showTooltip = true) { this.mod = mod ?? throw new ArgumentNullException(nameof(mod)); this.showTooltip = showTooltip; @@ -105,7 +105,7 @@ namespace osu.Game.Rulesets.UI updateMod(mod); } - private void updateMod(Mod value) + private void updateMod(IMod value) { modAcronym.Text = value.Acronym; modIcon.Icon = value.Icon ?? FontAwesome.Solid.Question; diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs index d18e0f9541..976f95cef8 100644 --- a/osu.Game/Rulesets/UI/ReplayRecorder.cs +++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.UI private SpectatorClient spectatorClient { get; set; } [Resolved] - private GameplayBeatmap gameplayBeatmap { get; set; } + private GameplayState gameplayState { get; set; } protected ReplayRecorder(Score target) { @@ -49,7 +49,7 @@ namespace osu.Game.Rulesets.UI inputManager = GetContainingInputManager(); - spectatorClient?.BeginPlaying(gameplayBeatmap, target); + spectatorClient?.BeginPlaying(gameplayState, target); } protected override void Dispose(bool isDisposing) @@ -70,16 +70,16 @@ namespace osu.Game.Rulesets.UI return base.OnMouseMove(e); } - public bool OnPressed(T action) + public bool OnPressed(KeyBindingPressEvent e) { - pressedActions.Add(action); + pressedActions.Add(e.Action); recordFrame(true); return false; } - public void OnReleased(T action) + public void OnReleased(KeyBindingReleaseEvent e) { - pressedActions.Remove(action); + pressedActions.Remove(e.Action); recordFrame(true); } diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index e6cd2aa3dc..a3f311c7a6 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -152,12 +152,12 @@ namespace osu.Game.Rulesets.UI { } - public bool OnPressed(T action) => Target.Children.OfType>().Any(c => c.OnPressed(action, Clock.Rate >= 0)); + public bool OnPressed(KeyBindingPressEvent e) => Target.Children.OfType>().Any(c => c.OnPressed(e.Action, Clock.Rate >= 0)); - public void OnReleased(T action) + public void OnReleased(KeyBindingReleaseEvent e) { foreach (var c in Target.Children.OfType>()) - c.OnReleased(action, Clock.Rate >= 0); + c.OnReleased(e.Action, Clock.Rate >= 0); } } diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 6ffdad211b..041c5ebef5 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Lists; using osu.Framework.Threading; using osu.Game.Beatmaps; @@ -43,7 +44,7 @@ namespace osu.Game.Rulesets.UI.Scrolling /// /// The maximum span of time that may be visible by the length of the scrolling axes. /// - private const double time_span_max = 10000; + private const double time_span_max = 20000; /// /// The step increase/decrease of the span of time visible by the length of the scrolling axes. @@ -132,7 +133,7 @@ namespace osu.Game.Rulesets.UI.Scrolling maxDuration = duration; // The slider multiplier is post-multiplied to determine the final velocity, but for relative scale beat lengths // the multiplier should not affect the effective timing point (the longest in the beatmap), so it is factored out here - baseBeatLength = timingPoints[i].BeatLength / Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier; + baseBeatLength = timingPoints[i].BeatLength / Beatmap.Difficulty.SliderMultiplier; } } } @@ -154,7 +155,7 @@ namespace osu.Game.Rulesets.UI.Scrolling return new MultiplierControlPoint(c.Time) { - Velocity = Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier, + Velocity = Beatmap.Difficulty.SliderMultiplier, BaseBeatLength = baseBeatLength, TimingPoint = lastTimingPoint, DifficultyPoint = lastDifficultyPoint @@ -171,7 +172,7 @@ namespace osu.Game.Rulesets.UI.Scrolling ControlPoints.AddRange(timingChanges); if (ControlPoints.Count == 0) - ControlPoints.Add(new MultiplierControlPoint { Velocity = Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier }); + ControlPoints.Add(new MultiplierControlPoint { Velocity = Beatmap.Difficulty.SliderMultiplier }); } protected override void LoadComplete() @@ -188,12 +189,12 @@ namespace osu.Game.Rulesets.UI.Scrolling /// The amount to adjust by. Greater than 0 if the scroll speed should be increased, less than 0 if it should be decreased. protected virtual void AdjustScrollSpeed(int amount) => this.TransformBindableTo(TimeRange, TimeRange.Value - amount * time_span_step, 200, Easing.OutQuint); - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { if (!UserScrollSpeedAdjustment) return false; - switch (action) + switch (e.Action) { case GlobalAction.IncreaseScrollSpeed: scheduleScrollSpeedAdjustment(1); @@ -209,7 +210,7 @@ namespace osu.Game.Rulesets.UI.Scrolling private ScheduledDelegate scheduledScrollSpeedAdjustment; - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { scheduledScrollSpeedAdjustment?.Cancel(); scheduledScrollSpeedAdjustment = null; diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 2f17167297..a1658b4cf3 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -67,10 +67,10 @@ namespace osu.Game.Scoring.Legacy // lazer replays get a really high version number. if (version < LegacyScoreEncoder.FIRST_LAZER_VERSION) - scoreInfo.Mods = scoreInfo.Mods.Append(currentRuleset.GetAllMods().OfType().Single()).ToArray(); + scoreInfo.Mods = scoreInfo.Mods.Append(currentRuleset.CreateMod()).ToArray(); currentBeatmap = workingBeatmap.GetPlayableBeatmap(currentRuleset.RulesetInfo, scoreInfo.Mods); - scoreInfo.Beatmap = currentBeatmap.BeatmapInfo; + scoreInfo.BeatmapInfo = currentBeatmap.BeatmapInfo; /* score.HpGraphString = */ sr.ReadString(); @@ -119,7 +119,7 @@ namespace osu.Game.Scoring.Legacy // before returning for database import, we must restore the database-sourced BeatmapInfo. // if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception. - score.ScoreInfo.Beatmap = workingBeatmap.BeatmapInfo; + score.ScoreInfo.BeatmapInfo = workingBeatmap.BeatmapInfo; return score; } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index 288552879c..58e4192f77 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -34,7 +34,7 @@ namespace osu.Game.Scoring.Legacy this.score = score; this.beatmap = beatmap; - if (score.ScoreInfo.Beatmap.RulesetID < 0 || score.ScoreInfo.Beatmap.RulesetID > 3) + if (score.ScoreInfo.BeatmapInfo.RulesetID < 0 || score.ScoreInfo.BeatmapInfo.RulesetID > 3) throw new ArgumentException("Only scores in the osu, taiko, catch, or mania rulesets can be encoded to the legacy score format.", nameof(score)); } @@ -44,7 +44,7 @@ namespace osu.Game.Scoring.Legacy { sw.Write((byte)(score.ScoreInfo.Ruleset.ID ?? 0)); sw.Write(LATEST_VERSION); - sw.Write(score.ScoreInfo.Beatmap.MD5Hash); + sw.Write(score.ScoreInfo.BeatmapInfo.MD5Hash); sw.Write(score.ScoreInfo.UserString); sw.Write($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}".ComputeMD5Hash()); sw.Write((ushort)(score.ScoreInfo.GetCount300() ?? 0)); 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 4fd1d00fef..5cf22f7945 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -7,7 +7,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; -using osu.Framework.Extensions; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; @@ -19,7 +19,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; } @@ -35,7 +35,7 @@ namespace osu.Game.Scoring public double Accuracy { get; set; } [JsonIgnore] - public string DisplayAccuracy => Accuracy.FormatAccuracy(); + public LocalisableString DisplayAccuracy => Accuracy.FormatAccuracy(); [JsonProperty(@"pp")] public double? PP { get; set; } @@ -150,7 +150,8 @@ namespace osu.Game.Scoring public int BeatmapInfoID { get; set; } [JsonIgnore] - public virtual BeatmapInfo Beatmap { get; set; } + [Column("Beatmap")] + public virtual BeatmapInfo BeatmapInfo { get; set; } [JsonIgnore] public long? OnlineScoreID { get; set; } @@ -209,13 +210,13 @@ namespace osu.Game.Scoring { 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); @@ -224,7 +225,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); @@ -243,7 +244,16 @@ namespace osu.Game.Scoring } } - public override string ToString() => $"{User} playing {Beatmap}"; + public ScoreInfo DeepClone() + { + var clone = (ScoreInfo)MemberwiseClone(); + + clone.Statistics = new Dictionary(clone.Statistics); + + return clone; + } + + public override string ToString() => $"{User} playing {BeatmapInfo}"; public bool Equals(ScoreInfo other) { diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index ebbdc8a109..cf22a8fda4 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -9,101 +9,78 @@ 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.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring.Legacy; namespace osu.Game.Scoring { - public class ScoreManager : DownloadableArchiveModelManager + public class ScoreManager : IModelManager, IModelFileManager, IModelDownloader, ICanAcceptFiles, IPostImports { - public override IEnumerable HandledExtensions => new[] { ".osr" }; - - protected override string[] HashableFileTypes => new[] { ".osr" }; - - protected override string ImportFromStablePath => Path.Combine("Data", "r"); - - private readonly RulesetStore rulesets; - private readonly Func beatmaps; - - [CanBeNull] + private readonly Scheduler scheduler; private readonly Func difficulties; - - [CanBeNull] private readonly OsuConfigManager configManager; + private readonly ScoreModelManager scoreModelManager; + private readonly ScoreModelDownloader scoreModelDownloader; - public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, IIpcHost importHost = null, - Func difficulties = null, OsuConfigManager configManager = null) - : base(storage, contextFactory, api, new ScoreStore(contextFactory, storage), importHost) + public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, Scheduler scheduler, + IIpcHost importHost = null, Func difficulties = null, OsuConfigManager configManager = null) { - this.rulesets = rulesets; - this.beatmaps = beatmaps; + this.scheduler = scheduler; this.difficulties = difficulties; this.configManager = configManager; + + scoreModelManager = new ScoreModelManager(rulesets, beatmaps, storage, contextFactory, importHost); + scoreModelDownloader = new ScoreModelDownloader(scoreModelManager, api, importHost); } - protected override ScoreInfo CreateModel(ArchiveReader archive) - { - if (archive == null) - return null; + public Score GetScore(ScoreInfo score) => scoreModelManager.GetScore(score); - using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)))) + public List GetAllUsableScores() => scoreModelManager.GetAllUsableScores(); + + public IEnumerable QueryScores(Expression> query) => scoreModelManager.QueryScores(query); + + public ScoreInfo Query(Expression> query) => scoreModelManager.Query(query); + + /// + /// Orders an array of s by total score. + /// + /// The array of s to reorder. + /// A to cancel the process. + /// The given ordered by decreasing total score. + public async Task OrderByTotalScoreAsync(ScoreInfo[] scores, CancellationToken cancellationToken = default) + { + var difficultyCache = difficulties?.Invoke(); + + if (difficultyCache != null) { - try + // Compute difficulties asynchronously first to prevent blocking via the GetTotalScore() call below. + foreach (var s in scores) { - return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo; - } - catch (LegacyScoreDecoder.BeatmapNotFoundException e) - { - Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error); - return null; + await difficultyCache.GetDifficultyAsync(s.BeatmapInfo, s.Ruleset, s.Mods, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); } } + + var totalScores = await Task.WhenAll(scores.Select(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken))).ConfigureAwait(false); + + return scores.Select((score, index) => (score, totalScore: totalScores[index])) + .OrderByDescending(g => g.totalScore) + .ThenBy(g => g.score.OnlineScoreID) + .Select(g => g.score) + .ToArray(); } - 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(); - if (file == null) - return; - - using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath)) - inputStream.CopyTo(outputStream); - } - - protected override IEnumerable GetStableImportPaths(Storage storage) - => storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) - .Select(path => storage.GetFullPath(path)); - - public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); - - public List GetAllUsableScores() => ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); - - public IEnumerable QueryScores(Expression> query) => ModelStore.ConsumableItems.AsNoTracking().Where(query); - - public ScoreInfo Query(Expression> query) => ModelStore.ConsumableItems.AsNoTracking().FirstOrDefault(query); - - protected override ArchiveDownloadRequest CreateDownloadRequest(ScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score); - - protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable items) - => base.CheckLocalAvailability(model, items) - || (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID)); - /// /// Retrieves a bindable that represents the total score of a . /// @@ -112,9 +89,9 @@ namespace osu.Game.Scoring /// /// The to retrieve the bindable for. /// The bindable containing the total score. - public Bindable GetBindableTotalScore(ScoreInfo score) + public Bindable GetBindableTotalScore([NotNull] ScoreInfo score) { - var bindable = new TotalScoreBindable(score, difficulties); + var bindable = new TotalScoreBindable(score, this); configManager?.BindWith(OsuSetting.ScoreDisplayMode, bindable.ScoringMode); return bindable; } @@ -127,7 +104,83 @@ namespace osu.Game.Scoring /// /// The to retrieve the bindable for. /// The bindable containing the formatted total score string. - public Bindable GetBindableTotalScoreString(ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); + public Bindable GetBindableTotalScoreString([NotNull] ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); + + /// + /// Retrieves the total score of a in the given . + /// The score is returned in a callback that is run on the update thread. + /// + /// The to calculate the total score of. + /// The callback to be invoked with the total score. + /// The to return the total score as. + /// A to cancel the process. + public void GetTotalScore([NotNull] ScoreInfo score, [NotNull] Action callback, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) + { + GetTotalScoreAsync(score, mode, cancellationToken) + .ContinueWith(s => scheduler.Add(() => callback(s.Result)), TaskContinuationOptions.OnlyOnRanToCompletion); + } + + /// + /// Retrieves the total score of a in the given . + /// + /// The to calculate the total score of. + /// The to return the total score as. + /// A to cancel the process. + /// The total score. + public async Task GetTotalScoreAsync([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) + { + if (score.BeatmapInfo == null) + return score.TotalScore; + + int beatmapMaxCombo; + double accuracy = score.Accuracy; + + if (score.IsLegacyScore) + { + if (score.RulesetID == 3) + { + // In osu!stable, a full-GREAT score has 100% accuracy in mania. Along with a full combo, the score becomes indistinguishable from a full-PERFECT score. + // To get around this, recalculate accuracy based on the hit statistics. + // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. + double maxBaseScore = score.Statistics.Select(kvp => kvp.Value).Sum() * Judgement.ToNumericResult(HitResult.Perfect); + double baseScore = score.Statistics.Select(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value).Sum(); + if (maxBaseScore > 0) + accuracy = baseScore / maxBaseScore; + } + + // This score is guaranteed to be an osu!stable score. + // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. + if (score.BeatmapInfo.MaxCombo != null) + beatmapMaxCombo = score.BeatmapInfo.MaxCombo.Value; + else + { + if (score.BeatmapInfo.ID == 0 || difficulties == null) + { + // We don't have enough information (max combo) to compute the score, so use the provided score. + return score.TotalScore; + } + + // We can compute the max combo locally after the async beatmap difficulty computation. + var difficulty = await difficulties().GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); + beatmapMaxCombo = difficulty.MaxCombo; + } + } + else + { + // 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.GetValueOrDefault(r)).Sum(); + } + + if (beatmapMaxCombo == 0) + return 0; + + var ruleset = score.Ruleset.CreateInstance(); + var scoreProcessor = ruleset.CreateScoreProcessor(); + scoreProcessor.Mods.Value = score.Mods; + + return (long)Math.Round(scoreProcessor.GetScore(mode, beatmapMaxCombo, accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); + } /// /// Provides the total score of a . Responds to changes in the currently-selected . @@ -137,99 +190,29 @@ namespace osu.Game.Scoring public readonly Bindable ScoringMode = new Bindable(); private readonly ScoreInfo score; - private readonly Func difficulties; + private readonly ScoreManager scoreManager; + + private CancellationTokenSource difficultyCalculationCancellationSource; /// /// Creates a new . /// /// The to provide the total score of. - /// A function to retrieve the . - public TotalScoreBindable(ScoreInfo score, Func difficulties) + /// The . + public TotalScoreBindable(ScoreInfo score, ScoreManager scoreManager) { this.score = score; - this.difficulties = difficulties; + this.scoreManager = scoreManager; ScoringMode.BindValueChanged(onScoringModeChanged, true); } - private IBindable difficultyBindable; - private CancellationTokenSource difficultyCancellationSource; - private void onScoringModeChanged(ValueChangedEvent mode) { - difficultyCancellationSource?.Cancel(); - difficultyCancellationSource = null; + difficultyCalculationCancellationSource?.Cancel(); + difficultyCalculationCancellationSource = new CancellationTokenSource(); - if (score.Beatmap == null) - { - Value = score.TotalScore; - return; - } - - int beatmapMaxCombo; - double accuracy = score.Accuracy; - - if (score.IsLegacyScore) - { - if (score.RulesetID == 3) - { - // In osu!stable, a full-GREAT score has 100% accuracy in mania. Along with a full combo, the score becomes indistinguishable from a full-PERFECT score. - // To get around this, recalculate accuracy based on the hit statistics. - // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. - double maxBaseScore = score.Statistics.Select(kvp => kvp.Value).Sum() * Judgement.ToNumericResult(HitResult.Perfect); - double baseScore = score.Statistics.Select(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value).Sum(); - if (maxBaseScore > 0) - accuracy = baseScore / maxBaseScore; - } - - // This score is guaranteed to be an osu!stable score. - // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. - if (score.Beatmap.MaxCombo == null) - { - if (score.Beatmap.ID == 0 || difficulties == null) - { - // We don't have enough information (max combo) to compute the score, so use the provided score. - Value = score.TotalScore; - return; - } - - // We can compute the max combo locally after the async beatmap difficulty computation. - difficultyBindable = difficulties().GetBindableDifficulty(score.Beatmap, score.Ruleset, score.Mods, (difficultyCancellationSource = new CancellationTokenSource()).Token); - difficultyBindable.BindValueChanged(d => - { - if (d.NewValue is StarDifficulty diff) - updateScore(diff.MaxCombo, accuracy); - }, true); - - return; - } - - beatmapMaxCombo = score.Beatmap.MaxCombo.Value; - } - else - { - // 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(); - } - - updateScore(beatmapMaxCombo, accuracy); - } - - private void updateScore(int beatmapMaxCombo, double accuracy) - { - if (beatmapMaxCombo == 0) - { - Value = 0; - return; - } - - var ruleset = score.Ruleset.CreateInstance(); - var scoreProcessor = ruleset.CreateScoreProcessor(); - - scoreProcessor.Mods.Value = score.Mods; - - Value = (long)Math.Round(scoreProcessor.GetScore(ScoringMode.Value, beatmapMaxCombo, accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); + scoreManager.GetTotalScore(score, s => Value = s, mode.NewValue, difficultyCalculationCancellationSource.Token); } } @@ -247,5 +230,149 @@ namespace osu.Game.Scoring this.totalScore.BindValueChanged(v => Value = v.NewValue.ToString("N0"), true); } } + + #region Implementation of IPostNotifications + + public Action PostNotification + { + set + { + scoreModelManager.PostNotification = value; + scoreModelDownloader.PostNotification = value; + } + } + + #endregion + + #region Implementation of IModelManager + + public IBindable> ItemUpdated => scoreModelManager.ItemUpdated; + + public IBindable> ItemRemoved => scoreModelManager.ItemRemoved; + + public Task ImportFromStableAsync(StableStorage stableStorage) + { + return scoreModelManager.ImportFromStableAsync(stableStorage); + } + + public void Export(ScoreInfo item) + { + scoreModelManager.Export(item); + } + + public void ExportModelTo(ScoreInfo model, Stream outputStream) + { + scoreModelManager.ExportModelTo(model, outputStream); + } + + public void Update(ScoreInfo item) + { + scoreModelManager.Update(item); + } + + public bool Delete(ScoreInfo item) + { + return scoreModelManager.Delete(item); + } + + public void Delete(List items, bool silent = false) + { + scoreModelManager.Delete(items, silent); + } + + public void Undelete(List items, bool silent = false) + { + scoreModelManager.Undelete(items, silent); + } + + public void Undelete(ScoreInfo item) + { + scoreModelManager.Undelete(item); + } + + public Task Import(params string[] paths) + { + return scoreModelManager.Import(paths); + } + + public Task Import(params ImportTask[] tasks) + { + return scoreModelManager.Import(tasks); + } + + public IEnumerable HandledExtensions => scoreModelManager.HandledExtensions; + + public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) + { + return scoreModelManager.Import(notification, tasks); + } + + public Task> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return scoreModelManager.Import(task, lowPriority, cancellationToken); + } + + public Task> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return scoreModelManager.Import(archive, lowPriority, cancellationToken); + } + + public Task> Import(ScoreInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return scoreModelManager.Import(item, archive, lowPriority, cancellationToken); + } + + public bool IsAvailableLocally(ScoreInfo model) + { + return scoreModelManager.IsAvailableLocally(model); + } + + #endregion + + #region Implementation of IModelFileManager + + public void ReplaceFile(ScoreInfo model, ScoreFileInfo file, Stream contents, string filename = null) + { + scoreModelManager.ReplaceFile(model, file, contents, filename); + } + + public void DeleteFile(ScoreInfo model, ScoreFileInfo file) + { + scoreModelManager.DeleteFile(model, file); + } + + public void AddFile(ScoreInfo model, Stream contents, string filename) + { + scoreModelManager.AddFile(model, contents, filename); + } + + #endregion + + #region Implementation of IModelDownloader + + public IBindable>> DownloadBegan => scoreModelDownloader.DownloadBegan; + + public IBindable>> DownloadFailed => scoreModelDownloader.DownloadFailed; + + public bool Download(ScoreInfo model, bool minimiseDownloadSize) + { + return scoreModelDownloader.Download(model, minimiseDownloadSize); + } + + public ArchiveDownloadRequest GetExistingDownload(ScoreInfo model) + { + return scoreModelDownloader.GetExistingDownload(model); + } + + #endregion + + #region Implementation of IPresentImports + + public Action>> PostImport + { + set => scoreModelManager.PostImport = value; + } + + #endregion } } diff --git a/osu.Game/Scoring/ScoreModelDownloader.cs b/osu.Game/Scoring/ScoreModelDownloader.cs new file mode 100644 index 0000000000..b3c1e2928a --- /dev/null +++ b/osu.Game/Scoring/ScoreModelDownloader.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Scoring +{ + public class ScoreModelDownloader : ModelDownloader + { + public ScoreModelDownloader(ScoreModelManager scoreManager, IAPIProvider api, IIpcHost importHost = null) + : base(scoreManager, api, importHost) + { + } + + protected override ArchiveDownloadRequest CreateDownloadRequest(ScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score); + } +} diff --git a/osu.Game/Scoring/ScoreModelManager.cs b/osu.Game/Scoring/ScoreModelManager.cs new file mode 100644 index 0000000000..c65a6acdfb --- /dev/null +++ b/osu.Game/Scoring/ScoreModelManager.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Rulesets; +using osu.Game.Scoring.Legacy; + +namespace osu.Game.Scoring +{ + public class ScoreModelManager : ArchiveModelManager + { + public override IEnumerable HandledExtensions => new[] { ".osr" }; + + protected override string[] HashableFileTypes => new[] { ".osr" }; + + protected override string ImportFromStablePath => Path.Combine("Data", "r"); + + private readonly RulesetStore rulesets; + private readonly Func beatmaps; + + public ScoreModelManager(RulesetStore rulesets, Func beatmaps, Storage storage, IDatabaseContextFactory contextFactory, IIpcHost importHost = null) + : base(storage, contextFactory, new ScoreStore(contextFactory, storage), importHost) + { + this.rulesets = rulesets; + this.beatmaps = beatmaps; + } + + protected override ScoreInfo CreateModel(ArchiveReader archive) + { + if (archive == null) + return null; + + using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)))) + { + try + { + return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo; + } + catch (LegacyScoreDecoder.BeatmapNotFoundException e) + { + Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error); + return null; + } + } + } + + public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); + + public List GetAllUsableScores() => ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); + + public IEnumerable QueryScores(Expression> query) => ModelStore.ConsumableItems.AsNoTracking().Where(query); + + public ScoreInfo Query(Expression> query) => ModelStore.ConsumableItems.AsNoTracking().FirstOrDefault(query); + + protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable items) + => base.CheckLocalAvailability(model, items) + || (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID)); + + public override void ExportModelTo(ScoreInfo model, Stream outputStream) + { + var file = model.Files.SingleOrDefault(); + if (file == null) + return; + + using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath)) + inputStream.CopyTo(outputStream); + } + + protected override IEnumerable GetStableImportPaths(Storage storage) + => storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) + .Select(path => storage.GetFullPath(path)); + } +} diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs index bb15983de3..82685e9a04 100644 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -34,7 +34,7 @@ namespace osu.Game.Scoring { var score = lookup.ScoreInfo; - var attributes = await difficultyCache.GetDifficultyAsync(score.Beatmap, score.Ruleset, score.Mods, token).ConfigureAwait(false); + var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, token).ConfigureAwait(false); // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. if (attributes.Attributes == null) diff --git a/osu.Game/Scoring/ScoreRank.cs b/osu.Game/Scoring/ScoreRank.cs index f3b4551ff8..64f7da9ba3 100644 --- a/osu.Game/Scoring/ScoreRank.cs +++ b/osu.Game/Scoring/ScoreRank.cs @@ -1,74 +1,44 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Scoring { - [LocalisableEnum(typeof(ScoreRankEnumLocalisationMapper))] public enum ScoreRank { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankD))] [Description(@"D")] D, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankC))] [Description(@"C")] C, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankB))] [Description(@"B")] B, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankA))] [Description(@"A")] A, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankS))] [Description(@"S")] S, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankSH))] [Description(@"S+")] SH, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankX))] [Description(@"SS")] X, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankXH))] [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/Scoring/ScoreStore.cs b/osu.Game/Scoring/ScoreStore.cs index f5c5cd5dad..fd1f5ae3ec 100644 --- a/osu.Game/Scoring/ScoreStore.cs +++ b/osu.Game/Scoring/ScoreStore.cs @@ -17,9 +17,9 @@ namespace osu.Game.Scoring protected override IQueryable AddIncludesForConsumption(IQueryable query) => base.AddIncludesForConsumption(query) - .Include(s => s.Beatmap) - .Include(s => s.Beatmap).ThenInclude(b => b.Metadata) - .Include(s => s.Beatmap).ThenInclude(b => b.BeatmapSet).ThenInclude(s => s.Metadata) + .Include(s => s.BeatmapInfo) + .Include(s => s.BeatmapInfo).ThenInclude(b => b.Metadata) + .Include(s => s.BeatmapInfo).ThenInclude(b => b.BeatmapSet).ThenInclude(s => s.Metadata) .Include(s => s.Ruleset); } } diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index a6fb94b151..a706934cce 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -11,6 +11,9 @@ namespace osu.Game.Screens { public abstract class BackgroundScreen : Screen, IEquatable { + protected const float TRANSITION_LENGTH = 500; + private const float x_movement_amount = 50; + private readonly bool animateOnEnter; public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; @@ -27,9 +30,6 @@ namespace osu.Game.Screens return other?.GetType() == GetType(); } - private const float transition_length = 500; - private const float x_movement_amount = 50; - protected override bool OnKeyDown(KeyDownEvent e) { // we don't want to handle escape key. @@ -55,8 +55,8 @@ namespace osu.Game.Screens this.FadeOut(); this.MoveToX(x_movement_amount); - this.FadeIn(transition_length, Easing.InOutQuart); - this.MoveToX(0, transition_length, Easing.InOutQuart); + this.FadeIn(TRANSITION_LENGTH, Easing.InOutQuart); + this.MoveToX(0, TRANSITION_LENGTH, Easing.InOutQuart); } base.OnEntering(last); @@ -64,7 +64,7 @@ namespace osu.Game.Screens public override void OnSuspending(IScreen next) { - this.MoveToX(-x_movement_amount, transition_length, Easing.InOutQuart); + this.MoveToX(-x_movement_amount, TRANSITION_LENGTH, Easing.InOutQuart); base.OnSuspending(next); } @@ -72,8 +72,8 @@ namespace osu.Game.Screens { if (IsLoaded) { - this.FadeOut(transition_length, Easing.OutExpo); - this.MoveToX(x_movement_amount, transition_length, Easing.OutExpo); + this.FadeOut(TRANSITION_LENGTH, Easing.OutExpo); + this.MoveToX(x_movement_amount, TRANSITION_LENGTH, Easing.OutExpo); } return base.OnExiting(next); @@ -82,7 +82,7 @@ namespace osu.Game.Screens public override void OnResuming(IScreen last) { if (IsLoaded) - this.MoveToX(0, transition_length, Easing.OutExpo); + this.MoveToX(0, TRANSITION_LENGTH, Easing.OutExpo); base.OnResuming(last); } } diff --git a/osu.Game/Screens/BackgroundScreenStack.cs b/osu.Game/Screens/BackgroundScreenStack.cs index 9c0c5da0fb..9f562a618e 100644 --- a/osu.Game/Screens/BackgroundScreenStack.cs +++ b/osu.Game/Screens/BackgroundScreenStack.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Screens; -using osuTK; namespace osu.Game.Screens { @@ -13,23 +12,26 @@ namespace osu.Game.Screens public BackgroundScreenStack() : base(false) { - Scale = new Vector2(1.06f); RelativeSizeAxes = Axes.Both; Anchor = Anchor.Centre; Origin = Anchor.Centre; } - //public float ParallaxAmount { set => parallax.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * value; } - - public void Push(BackgroundScreen screen) + /// + /// Attempt to push a new background screen to this stack. + /// + /// The screen to attempt to push. + /// Whether the push succeeded. For example, if the existing screen was already of the correct type this will return false. + public bool Push(BackgroundScreen screen) { if (screen == null) - return; + return false; if (EqualityComparer.Default.Equals((BackgroundScreen)CurrentScreen, screen)) - return; + return false; base.Push(screen); + return true; } } } diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index ff33f0c70d..dfe2992a7c 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -41,6 +41,8 @@ namespace osu.Game.Screens.Edit protected override int DefaultMaxValue => VALID_DIVISORS.Last(); protected override int DefaultPrecision => 1; + protected override Bindable CreateInstance() => new BindableBeatDivisor(); + /// /// Retrieves the appropriate colour for a beat divisor. /// diff --git a/osu.Game/Screens/Edit/ClipboardContent.cs b/osu.Game/Screens/Edit/ClipboardContent.cs index b2edbedccc..0348a7c15d 100644 --- a/osu.Game/Screens/Edit/ClipboardContent.cs +++ b/osu.Game/Screens/Edit/ClipboardContent.cs @@ -4,13 +4,12 @@ using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; -using osu.Game.IO.Serialization; using osu.Game.IO.Serialization.Converters; using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Edit { - public class ClipboardContent : IJsonSerializable + public class ClipboardContent { [JsonConverter(typeof(TypedListConverter))] public IList HitObjects; diff --git a/osu.Game/Screens/Edit/Components/Menus/DifficultyMenuItem.cs b/osu.Game/Screens/Edit/Components/Menus/DifficultyMenuItem.cs new file mode 100644 index 0000000000..c458b65607 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/Menus/DifficultyMenuItem.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Edit.Components.Menus +{ + public class DifficultyMenuItem : StatefulMenuItem + { + public BeatmapInfo BeatmapInfo { get; } + + public DifficultyMenuItem(BeatmapInfo beatmapInfo, bool selected, Action difficultyChangeFunc) + : base(beatmapInfo.Version ?? "(unnamed)", null) + { + BeatmapInfo = beatmapInfo; + State.Value = selected; + + if (!selected) + Action.Value = () => difficultyChangeFunc.Invoke(beatmapInfo); + } + + public override IconUsage? GetIconForState(bool state) => state ? (IconUsage?)FontAwesome.Solid.Check : null; + } +} 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 185f029d14..75d4d13f94 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -110,9 +110,9 @@ namespace osu.Game.Screens.Edit.Compose.Components bool selectionPerformed = performMouseDownActions(e); // even if a selection didn't occur, a drag event may still move the selection. - prepareSelectionMovement(); + bool movementPossible = prepareSelectionMovement(); - return selectionPerformed || e.Button == MouseButton.Left; + return selectionPerformed || (e.Button == MouseButton.Left && movementPossible); } protected SelectionBlueprint ClickedBlueprint { get; private set; } @@ -228,11 +228,11 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - public bool OnPressed(PlatformAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action.ActionType) + switch (e.Action) { - case PlatformActionType.SelectAll: + case PlatformAction.SelectAll: SelectAll(); return true; } @@ -240,7 +240,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - public void OnReleased(PlatformAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -427,19 +427,21 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Attempts to begin the movement of any selected blueprints. /// - private void prepareSelectionMovement() + /// Whether a movement is possible. + private bool prepareSelectionMovement() { if (!SelectionHandler.SelectedBlueprints.Any()) - return; + return false; // Any selected blueprint that is hovered can begin the movement of the group, however only the first item (according to SortForMovement) is used for movement. // A special case is added for when a click selection occurred before the drag if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) - return; + return false; // Movement is tracked from the blueprint of the earliest item, since it only makes sense to distance snap from that item movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).ToArray(); movementBlueprintOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSelectionPoint).ToArray(); + return true; } /// diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs index 4078661a26..3fc26fa974 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -1,12 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using osu.Framework.Bindables; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Screens.Edit.Compose.Components { @@ -15,69 +15,62 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public sealed class HitObjectOrderedSelectionContainer : Container> { + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + editorBeatmap.HitObjectUpdated += hitObjectUpdated; + } + + private void hitObjectUpdated(HitObject _) => SortInternal(); + public override void Add(SelectionBlueprint drawable) { + SortInternal(); base.Add(drawable); - bindStartTime(drawable); } public override bool Remove(SelectionBlueprint drawable) { - if (!base.Remove(drawable)) - return false; - - unbindStartTime(drawable); - return true; - } - - public override void Clear(bool disposeChildren) - { - base.Clear(disposeChildren); - unbindAllStartTimes(); - } - - private readonly Dictionary, IBindable> startTimeMap = new Dictionary, IBindable>(); - - private void bindStartTime(SelectionBlueprint blueprint) - { - var bindable = blueprint.Item.StartTimeBindable.GetBoundCopy(); - - bindable.BindValueChanged(_ => - { - if (LoadState >= LoadState.Ready) - SortInternal(); - }); - - startTimeMap[blueprint] = bindable; - } - - private void unbindStartTime(SelectionBlueprint blueprint) - { - startTimeMap[blueprint].UnbindAll(); - startTimeMap.Remove(blueprint); - } - - private void unbindAllStartTimes() - { - foreach (var kvp in startTimeMap) - kvp.Value.UnbindAll(); - startTimeMap.Clear(); + SortInternal(); + return base.Remove(drawable); } protected override int Compare(Drawable x, Drawable y) { - var xObj = (SelectionBlueprint)x; - var yObj = (SelectionBlueprint)y; + var xObj = ((SelectionBlueprint)x).Item; + var yObj = ((SelectionBlueprint)y).Item; // Put earlier blueprints towards the end of the list, so they handle input first - int i = yObj.Item.StartTime.CompareTo(xObj.Item.StartTime); - - if (i != 0) return i; + int result = yObj.StartTime.CompareTo(xObj.StartTime); + if (result != 0) return result; // Fall back to end time if the start time is equal. - i = yObj.Item.GetEndTime().CompareTo(xObj.Item.GetEndTime()); + result = yObj.GetEndTime().CompareTo(xObj.GetEndTime()); + if (result != 0) return result; - return i == 0 ? CompareReverseChildID(y, x) : i; + // As a final fallback, use combo information if available. + if (xObj is IHasComboInformation xHasCombo && yObj is IHasComboInformation yHasCombo) + { + result = yHasCombo.ComboIndex.CompareTo(xHasCombo.ComboIndex); + if (result != 0) return result; + + result = yHasCombo.IndexInCurrentCombo.CompareTo(xHasCombo.IndexInCurrentCombo); + if (result != 0) return result; + } + + return CompareReverseChildID(y, x); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorBeatmap != null) + editorBeatmap.HitObjectUpdated -= hitObjectUpdated; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..95b4b2fe53 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Layout; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class RectangularPositionSnapGrid : CompositeDrawable + { + /// + /// The position of the origin of this in local coordinates. + /// + public Vector2 StartPosition { get; } + + private Vector2 spacing = Vector2.One; + + /// + /// The spacing between grid lines of this . + /// + public Vector2 Spacing + { + get => spacing; + set + { + if (spacing.X <= 0 || spacing.Y <= 0) + throw new ArgumentException("Grid spacing must be positive."); + + spacing = value; + gridCache.Invalidate(); + } + } + + private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); + + public RectangularPositionSnapGrid(Vector2 startPosition) + { + StartPosition = startPosition; + + AddLayout(gridCache); + } + + protected override void Update() + { + base.Update(); + + if (!gridCache.IsValid) + { + ClearInternal(); + createContent(); + gridCache.Validate(); + } + } + + private void createContent() + { + var drawSize = DrawSize; + + generateGridLines(Direction.Horizontal, StartPosition.Y, 0, -Spacing.Y); + generateGridLines(Direction.Horizontal, StartPosition.Y, drawSize.Y, Spacing.Y); + + generateGridLines(Direction.Vertical, StartPosition.X, 0, -Spacing.X); + generateGridLines(Direction.Vertical, StartPosition.X, drawSize.X, Spacing.X); + } + + private void generateGridLines(Direction direction, float startPosition, float endPosition, float step) + { + int index = 0; + float currentPosition = startPosition; + + while ((endPosition - currentPosition) * Math.Sign(step) > 0) + { + var gridLine = new Box + { + Colour = Colour4.White, + Alpha = index == 0 ? 0.3f : 0.1f, + EdgeSmoothness = new Vector2(0.2f) + }; + + if (direction == Direction.Horizontal) + { + gridLine.RelativeSizeAxes = Axes.X; + gridLine.Height = 1; + gridLine.Y = currentPosition; + } + else + { + gridLine.RelativeSizeAxes = Axes.Y; + gridLine.Width = 1; + gridLine.X = currentPosition; + } + + AddInternal(gridLine); + + index += 1; + currentPosition = startPosition + index * step; + } + } + + public Vector2 GetSnappedPosition(Vector2 original) + { + Vector2 relativeToStart = original - StartPosition; + Vector2 offset = Vector2.Divide(relativeToStart, Spacing); + Vector2 roundedOffset = new Vector2(MathF.Round(offset.X), MathF.Round(offset.Y)); + + return StartPosition + Vector2.Multiply(roundedOffset, Spacing); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index dc457b5320..be52a968bb 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -64,7 +64,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private bool canScaleX; /// - /// Whether vertical scale support should be enabled. + /// Whether horizontal scaling support should be enabled. /// public bool CanScaleX { @@ -81,7 +81,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private bool canScaleY; /// - /// Whether horizontal scale support should be enabled. + /// Whether vertical scaling support should be enabled. /// public bool CanScaleY { @@ -95,6 +95,40 @@ namespace osu.Game.Screens.Edit.Compose.Components } } + private bool canFlipX; + + /// + /// Whether horizontal flipping support should be enabled. + /// + public bool CanFlipX + { + get => canFlipX; + set + { + if (canFlipX == value) return; + + canFlipX = value; + recreate(); + } + } + + private bool canFlipY; + + /// + /// Whether vertical flipping support should be enabled. + /// + public bool CanFlipY + { + get => canFlipY; + set + { + if (canFlipY == value) return; + + canFlipY = value; + recreate(); + } + } + private string text; public string Text @@ -142,10 +176,10 @@ namespace osu.Game.Screens.Edit.Compose.Components return CanReverse && runOperationFromHotkey(OnReverse); case Key.H: - return CanScaleX && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Horizontal) ?? false); + return CanFlipX && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Horizontal) ?? false); case Key.J: - return CanScaleY && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Vertical) ?? false); + return CanFlipY && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Vertical) ?? false); } return base.OnKeyDown(e); @@ -214,6 +248,8 @@ namespace osu.Game.Screens.Edit.Compose.Components if (CanScaleX) addXScaleComponents(); if (CanScaleX && CanScaleY) addFullScaleComponents(); if (CanScaleY) addYScaleComponents(); + if (CanFlipX) addXFlipComponents(); + if (CanFlipY) addYFlipComponents(); if (CanRotate) addRotationComponents(); if (CanReverse) addButton(FontAwesome.Solid.Backward, "Reverse pattern (Ctrl-G)", () => OnReverse?.Invoke()); } @@ -231,8 +267,6 @@ namespace osu.Game.Screens.Edit.Compose.Components private void addYScaleComponents() { - addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically (Ctrl-J)", () => OnFlip?.Invoke(Direction.Vertical)); - addScaleHandle(Anchor.TopCentre); addScaleHandle(Anchor.BottomCentre); } @@ -247,12 +281,20 @@ namespace osu.Game.Screens.Edit.Compose.Components private void addXScaleComponents() { - addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally (Ctrl-H)", () => OnFlip?.Invoke(Direction.Horizontal)); - addScaleHandle(Anchor.CentreLeft); addScaleHandle(Anchor.CentreRight); } + private void addXFlipComponents() + { + addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally (Ctrl-H)", () => OnFlip?.Invoke(Direction.Horizontal)); + } + + private void addYFlipComponents() + { + addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically (Ctrl-J)", () => OnFlip?.Invoke(Direction.Vertical)); + } + private void addButton(IconUsage icon, string tooltip, Action action) { var button = new SelectionBoxButton(icon, tooltip) diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 8939be925a..ee35b6a47c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -137,11 +137,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether any items could be reversed. public virtual bool HandleReverse() => false; - public bool OnPressed(PlatformAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action.ActionMethod) + switch (e.Action) { - case PlatformActionMethod.Delete: + case PlatformAction.Delete: DeleteSelected(); return true; } @@ -149,7 +149,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - public void OnReleased(PlatformAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -191,7 +191,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The blueprint. /// The mouse event responsible for selection. /// Whether a selection was performed. - internal bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) + internal virtual bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { if (e.ShiftPressed && e.Button == MouseButton.Right) { diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index a642768574..73c38ba23f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -13,11 +13,9 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Graphics; -using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -31,22 +29,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } - [Resolved] - private OsuColour colours { get; set; } - private DragEvent lastDragEvent; private Bindable placement; private SelectionBlueprint placementBlueprint; - private SelectableAreaBackground backgroundBox; - - // we only care about checking vertical validity. - // this allows selecting and dragging selections before time=0. - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) - { - float localY = ToLocalSpace(screenSpacePos).Y; - return DrawRectangle.Top <= localY && DrawRectangle.Bottom >= localY; - } + // We want children within the timeline to be interactable + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ScreenSpaceDrawQuad.Contains(screenSpacePos); public TimelineBlueprintContainer(HitObjectComposer composer) : base(composer) @@ -61,7 +49,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [BackgroundDependencyLoader] private void load() { - AddInternal(backgroundBox = new SelectableAreaBackground + AddInternal(new SelectableAreaBackground { Colour = Color4.Black, Depth = float.MaxValue, @@ -100,18 +88,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; - protected override bool OnHover(HoverEvent e) - { - backgroundBox.FadeColour(colours.BlueLighter, 120, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - backgroundBox.FadeColour(Color4.Black, 600, Easing.OutQuint); - base.OnHoverLost(e); - } - protected override void OnDrag(DragEvent e) { handleScrollViaDrag(e); @@ -184,7 +160,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { return new TimelineHitObjectBlueprint(item) { - OnDragHandled = handleScrollViaDrag + OnDragHandled = handleScrollViaDrag, }; } @@ -212,6 +188,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private class SelectableAreaBackground : CompositeDrawable { + [Resolved] + private OsuColour colours { get; set; } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + float localY = ToLocalSpace(screenSpacePos).Y; + return DrawRectangle.Top <= localY && DrawRectangle.Bottom >= localY; + } + [BackgroundDependencyLoader] private void load() { @@ -235,114 +220,17 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } }); } - } - internal class TimelineSelectionHandler : EditorSelectionHandler, IKeyBindingHandler - { - // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation - public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; - - public bool OnPressed(GlobalAction action) + protected override bool OnHover(HoverEvent e) { - switch (action) - { - case GlobalAction.EditorNudgeLeft: - nudgeSelection(-1); - return true; - - case GlobalAction.EditorNudgeRight: - nudgeSelection(1); - return true; - } - - return false; + this.FadeColour(colours.BlueLighter, 120, Easing.OutQuint); + return base.OnHover(e); } - public void OnReleased(GlobalAction action) + protected override void OnHoverLost(HoverLostEvent e) { - } - - /// - /// Nudge the current selection by the specified multiple of beat divisor lengths, - /// based on the timing at the first object in the selection. - /// - /// The direction and count of beat divisor lengths to adjust. - private void nudgeSelection(int amount) - { - var selected = EditorBeatmap.SelectedHitObjects; - - if (selected.Count == 0) - return; - - var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(selected.First().StartTime); - double adjustment = timingPoint.BeatLength / EditorBeatmap.BeatDivisor * amount; - - EditorBeatmap.PerformOnSelection(h => - { - h.StartTime += adjustment; - EditorBeatmap.Update(h); - }); - } - } - - private class TimelineDragBox : DragBox - { - // the following values hold the start and end X positions of the drag box in the timeline's local space, - // but with zoom unapplied in order to be able to compensate for positional changes - // while the timeline is being zoomed in/out. - private float? selectionStart; - private float selectionEnd; - - [Resolved] - private Timeline timeline { get; set; } - - public TimelineDragBox(Action performSelect) - : base(performSelect) - { - } - - protected override Drawable CreateBox() => new Box - { - RelativeSizeAxes = Axes.Y, - Alpha = 0.3f - }; - - public override bool HandleDrag(MouseButtonEvent e) - { - selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom; - - // only calculate end when a transition is not in progress to avoid bouncing. - if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom)) - selectionEnd = e.MousePosition.X / timeline.CurrentZoom; - - updateDragBoxPosition(); - return true; - } - - private void updateDragBoxPosition() - { - if (selectionStart == null) - return; - - float rescaledStart = selectionStart.Value * timeline.CurrentZoom; - float rescaledEnd = selectionEnd * timeline.CurrentZoom; - - Box.X = Math.Min(rescaledStart, rescaledEnd); - Box.Width = Math.Abs(rescaledStart - rescaledEnd); - - var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat; - - // we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment. - boxScreenRect.Y -= boxScreenRect.Height; - boxScreenRect.Height *= 2; - - PerformSelection?.Invoke(boxScreenRect); - } - - public override void Hide() - { - base.Hide(); - selectionStart = null; + this.FadeColour(Color4.Black, 600, Easing.OutQuint); + base.OnHoverLost(e); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs new file mode 100644 index 0000000000..8aad8aa6dc --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Utils; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class TimelineDragBox : DragBox + { + // the following values hold the start and end X positions of the drag box in the timeline's local space, + // but with zoom unapplied in order to be able to compensate for positional changes + // while the timeline is being zoomed in/out. + private float? selectionStart; + private float selectionEnd; + + [Resolved] + private Timeline timeline { get; set; } + + public TimelineDragBox(Action performSelect) + : base(performSelect) + { + } + + protected override Drawable CreateBox() => new Box + { + RelativeSizeAxes = Axes.Y, + Alpha = 0.3f + }; + + public override bool HandleDrag(MouseButtonEvent e) + { + // The dragbox should only be active if the mouseDownPosition.Y is within this drawable's bounds. + float localY = ToLocalSpace(e.ScreenSpaceMouseDownPosition).Y; + if (DrawRectangle.Top > localY || DrawRectangle.Bottom < localY) + return false; + + selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom; + + // only calculate end when a transition is not in progress to avoid bouncing. + if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom)) + selectionEnd = e.MousePosition.X / timeline.CurrentZoom; + + updateDragBoxPosition(); + return true; + } + + private void updateDragBoxPosition() + { + if (selectionStart == null) + return; + + float rescaledStart = selectionStart.Value * timeline.CurrentZoom; + float rescaledEnd = selectionEnd * timeline.CurrentZoom; + + Box.X = Math.Min(rescaledStart, rescaledEnd); + Box.Width = Math.Abs(rescaledStart - rescaledEnd); + + var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat; + + // we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment. + boxScreenRect.Y -= boxScreenRect.Height; + boxScreenRect.Height *= 2; + + PerformSelection?.Invoke(boxScreenRect); + } + + public override void Hide() + { + base.Hide(); + selectionStart = null; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 377c37c4c7..911c9fea51 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; @@ -38,7 +37,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Bindable startTime; private Bindable indexInCurrentComboBindable; + private Bindable comboIndexBindable; + private Bindable comboIndexWithOffsetsBindable; + private Bindable displayColourBindable; private readonly ExtendableCircle circle; @@ -121,7 +123,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy(); - comboIndexBindable.BindValueChanged(_ => updateColour(), true); + comboIndexWithOffsetsBindable = comboInfo.ComboIndexWithOffsetsBindable.GetBoundCopy(); + + comboIndexBindable.BindValueChanged(_ => updateColour()); + comboIndexWithOffsetsBindable.BindValueChanged(_ => updateColour(), true); skin.SourceChanged += updateColour; break; @@ -153,25 +158,17 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline break; case IHasComboInformation combo: - { - var comboColours = skin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); - colour = combo.GetComboColour(comboColours); + colour = combo.GetComboColour(skin); break; - } default: return; } if (IsSelected) - { border.Show(); - colour = colour.Lighten(0.3f); - } else - { border.Hide(); - } if (Item is IHasDuration duration && duration.Duration > 0) circle.Colour = ColourInfo.GradientHorizontal(colour, colour.Lighten(0.4f)); @@ -210,14 +207,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline for (int i = 0; i < repeats.RepeatCount; i++) { - repeatsContainer.Add(new Circle + repeatsContainer.Add(new Tick { - Size = new Vector2(circle_size / 3), - Alpha = 0.2f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.X, - X = (float)(i + 1) / (repeats.RepeatCount + 1), + X = (float)(i + 1) / (repeats.RepeatCount + 1) }); } } @@ -231,6 +223,17 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft; + private class Tick : Circle + { + public Tick() + { + Size = new Vector2(circle_size / 4); + Anchor = Anchor.CentreLeft; + Origin = Anchor.Centre; + RelativePositionAxes = Axes.X; + } + } + public class DragArea : Circle { private readonly HitObject hitObject; @@ -302,20 +305,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateState() { - if (hasMouseDown) - { - this.ScaleTo(0.7f, 200, Easing.OutQuint); - } - else if (IsHovered) - { - this.ScaleTo(0.8f, 200, Easing.OutQuint); - } - else - { - this.ScaleTo(0.6f, 200, Easing.OutQuint); - } + float scale = 0.5f; - this.FadeTo(IsHovered || hasMouseDown ? 0.8f : 0.2f, 200, Easing.OutQuint); + if (hasMouseDown) + scale = 0.6f; + else if (IsHovered) + scale = 0.7f; + + this.ScaleTo(scale, 200, Easing.OutQuint); + this.FadeTo(IsHovered || hasMouseDown ? 1f : 0.9f, 200, Easing.OutQuint); } [Resolved] diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs new file mode 100644 index 0000000000..845a671e2c --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs @@ -0,0 +1,129 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + internal class TimelineSelectionHandler : EditorSelectionHandler, IKeyBindingHandler + { + [Resolved] + private Timeline timeline { get; set; } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ScreenSpaceDrawQuad.Contains(screenSpacePos); + + // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.EditorNudgeLeft: + nudgeSelection(-1); + return true; + + case GlobalAction.EditorNudgeRight: + nudgeSelection(1); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + /// + /// Nudge the current selection by the specified multiple of beat divisor lengths, + /// based on the timing at the first object in the selection. + /// + /// The direction and count of beat divisor lengths to adjust. + private void nudgeSelection(int amount) + { + var selected = EditorBeatmap.SelectedHitObjects; + + if (selected.Count == 0) + return; + + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(selected.First().StartTime); + double adjustment = timingPoint.BeatLength / EditorBeatmap.BeatDivisor * amount; + + EditorBeatmap.PerformOnSelection(h => + { + h.StartTime += adjustment; + EditorBeatmap.Update(h); + }); + } + + /// + /// The "pivot" object, used in range selection mode. + /// When in range selection, the range to select is determined by the pivot object + /// (last existing object interacted with prior to holding down Shift) + /// and by the object clicked last when Shift was pressed. + /// + [CanBeNull] + private HitObject pivot; + + internal override bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) + { + if (e.ShiftPressed && e.Button == MouseButton.Left && pivot != null) + { + handleRangeSelection(blueprint, e.ControlPressed); + return true; + } + + bool result = base.MouseDownSelectionRequested(blueprint, e); + // ensure that the object wasn't removed by the base implementation before making it the new pivot. + if (EditorBeatmap.HitObjects.Contains(blueprint.Item)) + pivot = blueprint.Item; + return result; + } + + /// + /// Handles a request for range selection (triggered when Shift is held down). + /// + /// The blueprint which was clicked in range selection mode. + /// + /// Whether the selection should be cumulative. + /// In cumulative mode, consecutive range selections will shift the pivot (which usually stays fixed for the duration of a range selection) + /// and will never deselect an object that was previously selected. + /// + private void handleRangeSelection(SelectionBlueprint blueprint, bool cumulative) + { + var clickedObject = blueprint.Item; + + Debug.Assert(pivot != null); + + double rangeStart = Math.Min(clickedObject.StartTime, pivot.StartTime); + double rangeEnd = Math.Max(clickedObject.GetEndTime(), pivot.GetEndTime()); + + var newSelection = new HashSet(EditorBeatmap.HitObjects.Where(obj => isInRange(obj, rangeStart, rangeEnd))); + + if (cumulative) + { + pivot = clickedObject; + newSelection.UnionWith(EditorBeatmap.SelectedHitObjects); + } + + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.AddRange(newSelection); + + bool isInRange(HitObject hitObject, double start, double end) + => hitObject.StartTime >= start && hitObject.GetEndTime() <= end; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index b56f9bee14..926a2ad4e0 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -9,13 +9,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; -using osu.Game.Skinning; namespace osu.Game.Screens.Edit.Compose { @@ -73,20 +73,20 @@ namespace osu.Game.Screens.Edit.Compose { Debug.Assert(ruleset != null); - return new RulesetSkinProvidingContainer(ruleset, EditorBeatmap.PlayableBeatmap, beatmap.Value.Skin).WithChild(content); + return new EditorSkinProvidingContainer(EditorBeatmap).WithChild(content); } #region Input Handling - public bool OnPressed(PlatformAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action.ActionType == PlatformActionType.Copy) - host.GetClipboard().SetText(formatSelectionAsString()); + if (e.Action == PlatformAction.Copy) + host.GetClipboard()?.SetText(formatSelectionAsString()); return false; } - public void OnReleased(PlatformAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 986a4efb28..1170658abb 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -55,7 +56,7 @@ namespace osu.Game.Screens.Edit public override bool DisallowExternalBeatmapRulesetChanges => true; - public override bool AllowRateAdjustments => false; + public override bool? AllowTrackAdjustments => false; protected bool HasUnsavedChanges => lastSavedHash != changeHandler.CurrentStateHash; @@ -75,6 +76,9 @@ namespace osu.Game.Screens.Edit private Container screenContainer; + [CanBeNull] + private readonly EditorLoader loader; + private EditorScreen currentScreen; private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); @@ -101,6 +105,11 @@ namespace osu.Game.Screens.Edit [Resolved] private MusicController music { get; set; } + public Editor(EditorLoader loader = null) + { + this.loader = loader; + } + [BackgroundDependencyLoader] private void load(OsuColour colours, OsuConfigManager config) { @@ -128,7 +137,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) { @@ -153,7 +162,7 @@ namespace osu.Game.Screens.Edit // todo: remove caching of this and consume via editorBeatmap? dependencies.Cache(beatDivisor); - AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.Skin)); + AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.GetSkin())); dependencies.CacheAs(editorBeatmap); changeHandler = new EditorChangeHandler(editorBeatmap); dependencies.CacheAs(changeHandler); @@ -308,6 +317,16 @@ namespace osu.Game.Screens.Edit /// public void UpdateClockSource() => clock.ChangeSource(Beatmap.Value.Track); + /// + /// Restore the editor to a provided state. + /// + /// The state to restore. + public void RestoreState([NotNull] EditorState state) => Schedule(() => + { + clock.Seek(state.Time); + clipboard.Value = state.ClipboardContent; + }); + protected void Save() { // no longer new after first user-triggered save. @@ -328,31 +347,31 @@ namespace osu.Game.Screens.Edit clock.ProcessFrame(); } - public bool OnPressed(PlatformAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action.ActionType) + switch (e.Action) { - case PlatformActionType.Cut: + case PlatformAction.Cut: Cut(); return true; - case PlatformActionType.Copy: + case PlatformAction.Copy: Copy(); return true; - case PlatformActionType.Paste: + case PlatformAction.Paste: Paste(); return true; - case PlatformActionType.Undo: + case PlatformAction.Undo: Undo(); return true; - case PlatformActionType.Redo: + case PlatformAction.Redo: Redo(); return true; - case PlatformActionType.Save: + case PlatformAction.Save: Save(); return true; } @@ -360,7 +379,7 @@ namespace osu.Game.Screens.Edit return false; } - public void OnReleased(PlatformAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -415,9 +434,9 @@ namespace osu.Game.Screens.Edit return true; } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Back: // as we don't want to display the back button, manual handling of exit action is required. @@ -449,7 +468,7 @@ namespace osu.Game.Screens.Edit } } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -489,7 +508,8 @@ namespace osu.Game.Screens.Edit if (isNewBeatmap || HasUnsavedChanges) { - dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave)); + samplePlaybackDisabled.Value = true; + dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave, cancelExit)); return true; } } @@ -605,19 +625,14 @@ namespace osu.Game.Screens.Edit { var lastScreen = currentScreen; - lastScreen? - .ScaleTo(0.98f, 200, Easing.OutQuint) - .FadeOut(200, Easing.OutQuint); + lastScreen?.Hide(); try { if ((currentScreen = screenContainer.SingleOrDefault(s => s.Type == e.NewValue)) != null) { screenContainer.ChangeChildDepth(currentScreen, lastScreen?.Depth + 1 ?? 0); - - currentScreen - .ScaleTo(1, 200, Easing.OutQuint) - .FadeIn(200, Easing.OutQuint); + currentScreen.Show(); return; } @@ -650,7 +665,10 @@ namespace osu.Game.Screens.Edit LoadComponentAsync(currentScreen, newScreen => { if (newScreen == currentScreen) + { screenContainer.Add(newScreen); + newScreen.Show(); + } }); } finally @@ -705,11 +723,46 @@ namespace osu.Game.Screens.Edit if (RuntimeInfo.IsDesktop) fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap)); + fileMenuItems.Add(new EditorMenuItemSpacer()); + + var beatmapSet = beatmapManager.QueryBeatmapSet(bs => bs.ID == Beatmap.Value.BeatmapSetInfo.ID) ?? playableBeatmap.BeatmapInfo.BeatmapSet; + + var difficultyItems = new List(); + + foreach (var rulesetBeatmaps in beatmapSet.Beatmaps.GroupBy(b => b.RulesetID).OrderBy(group => group.Key)) + { + if (difficultyItems.Count > 0) + difficultyItems.Add(new EditorMenuItemSpacer()); + + foreach (var beatmap in rulesetBeatmaps.OrderBy(b => b.StarDifficulty)) + difficultyItems.Add(createDifficultyMenuItem(beatmap)); + } + + fileMenuItems.Add(new EditorMenuItem("Change difficulty") { Items = difficultyItems }); + fileMenuItems.Add(new EditorMenuItemSpacer()); fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)); return fileMenuItems; } + private DifficultyMenuItem createDifficultyMenuItem(BeatmapInfo beatmapInfo) + { + bool isCurrentDifficulty = playableBeatmap.BeatmapInfo.Equals(beatmapInfo); + return new DifficultyMenuItem(beatmapInfo, isCurrentDifficulty, SwitchToDifficulty); + } + + protected void SwitchToDifficulty(BeatmapInfo nextBeatmap) => loader?.ScheduleDifficultySwitch(nextBeatmap, new EditorState + { + Time = clock.CurrentTimeAccurate, + ClipboardContent = editorBeatmap.BeatmapInfo.RulesetID == nextBeatmap.RulesetID ? clipboard.Value : string.Empty + }); + + private void cancelExit() + { + samplePlaybackDisabled.Value = false; + loader?.CancelPendingDifficultySwitch(); + } + public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime); diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index be53abbd55..64eb6225fa 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; + public readonly EditorBeatmapSkin BeatmapSkin; [Resolved] private BindableBeatDivisor beatDivisor { get; set; } + [Resolved] + private EditorClock editorClock { get; set; } + private readonly IBeatmapProcessor beatmapProcessor; private readonly Dictionary> startTimeBindables = new Dictionary>(); @@ -59,7 +69,8 @@ namespace osu.Game.Screens.Edit public EditorBeatmap(IBeatmap playableBeatmap, ISkin beatmapSkin = null) { PlayableBeatmap = playableBeatmap; - BeatmapSkin = beatmapSkin; + if (beatmapSkin is Skin skin) + BeatmapSkin = new EditorBeatmapSkin(skin); beatmapProcessor = playableBeatmap.BeatmapInfo.Ruleset?.CreateInstance().CreateBeatmapProcessor(PlayableBeatmap); @@ -75,6 +86,12 @@ namespace osu.Game.Screens.Edit public BeatmapMetadata Metadata => PlayableBeatmap.Metadata; + public BeatmapDifficulty Difficulty + { + get => PlayableBeatmap.Difficulty; + set => PlayableBeatmap.Difficulty = value; + } + public ControlPointInfo ControlPointInfo { get => PlayableBeatmap.ControlPointInfo; @@ -238,6 +255,8 @@ namespace osu.Game.Screens.Edit if (batchPendingUpdates.Count > 0) UpdateState(); + + hasTiming.Value = !ReferenceEquals(ControlPointInfo.TimingPointAt(editorClock.CurrentTime), TimingControlPoint.DEFAULT); } protected override void UpdateState() @@ -273,7 +292,7 @@ namespace osu.Game.Screens.Edit /// public void Clear() => RemoveRange(HitObjects.ToArray()); - private void processHitObject(HitObject hitObject) => hitObject.ApplyDefaults(ControlPointInfo, BeatmapInfo.BaseDifficulty); + private void processHitObject(HitObject hitObject) => hitObject.ApplyDefaults(ControlPointInfo, PlayableBeatmap.Difficulty); private void trackStartTime(HitObject hitObject) { diff --git a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs new file mode 100644 index 0000000000..429df85904 --- /dev/null +++ b/osu.Game/Screens/Edit/EditorBeatmapSkin.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 System; +using System.Linq; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit +{ + /// + /// A beatmap skin which is being edited. + /// + public class EditorBeatmapSkin : ISkin + { + public event Action BeatmapSkinChanged; + + /// + /// The combo colours of this skin. + /// If empty, the default combo colours will be used. + /// + public readonly BindableList ComboColours; + + private readonly Skin skin; + + public EditorBeatmapSkin(Skin skin) + { + this.skin = skin; + + ComboColours = new BindableList(); + if (skin.Configuration.ComboColours != null) + ComboColours.AddRange(skin.Configuration.ComboColours.Select(c => (Colour4)c)); + ComboColours.BindCollectionChanged((_, __) => updateColours()); + } + + private void invokeSkinChanged() => BeatmapSkinChanged?.Invoke(); + + private void updateColours() + { + skin.Configuration.CustomComboColours = ComboColours.Select(c => (Color4)c).ToList(); + invokeSkinChanged(); + } + + #region Delegated ISkin implementation + + 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); + + #endregion + } +} diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 772f6ea192..ba83261731 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -150,8 +150,6 @@ namespace osu.Game.Screens.Edit if (seekTime < timingPoint.Time && timingPoint != ControlPointInfo.TimingPoints.First()) seekTime = timingPoint.Time; - // Ensure the sought point is within the boundaries - seekTime = Math.Clamp(seekTime, 0, TrackLength); SeekSmoothlyTo(seekTime); } @@ -190,6 +188,9 @@ namespace osu.Game.Screens.Edit seekingOrStopped.Value = IsSeeking = true; ClearTransforms(); + + // Ensure the sought point is within the boundaries + position = Math.Clamp(position, 0, TrackLength); return underlyingClock.Seek(position); } @@ -288,7 +289,7 @@ namespace osu.Game.Screens.Edit } private void transformSeekTo(double seek, double duration = 0, Easing easing = Easing.None) - => this.TransformTo(this.PopulateTransform(new TransformSeek(), seek, duration, easing)); + => this.TransformTo(this.PopulateTransform(new TransformSeek(), Math.Clamp(seek, 0, TrackLength), duration, easing)); private double currentTime { diff --git a/osu.Game/Screens/Edit/EditorLoader.cs b/osu.Game/Screens/Edit/EditorLoader.cs new file mode 100644 index 0000000000..2a01a5b6b2 --- /dev/null +++ b/osu.Game/Screens/Edit/EditorLoader.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.Edit +{ + /// + /// Transition screen for the editor. + /// Used to avoid backing out to main menu/song select when switching difficulties from within the editor. + /// + public class EditorLoader : ScreenWithBeatmapBackground + { + /// + /// The stored state from the last editor opened. + /// This will be read by the next editor instance to be opened to restore any relevant previous state. + /// + [CanBeNull] + private EditorState state; + + public override float BackgroundParallaxAmount => 0.1f; + + public override bool AllowBackButton => false; + + public override bool HideOverlaysOnEnter => true; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [CanBeNull] + private ScheduledDelegate scheduledDifficultySwitch; + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + new LoadingSpinner(true) + { + State = { Value = Visibility.Visible }, + } + }); + } + + protected virtual Editor CreateEditor() => new Editor(this); + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (!resuming) + { + // the push cannot happen in OnEntering() or similar (even if scheduled), because the transition from main menu will look bad. + // that is because this screen pushing the editor makes it no longer current, and OsuScreen checks if the screen is current + // before enqueueing this screen's LogoArriving onto the logo animation sequence. + pushEditor(); + } + } + + public void ScheduleDifficultySwitch(BeatmapInfo nextBeatmap, EditorState editorState) + { + scheduledDifficultySwitch?.Cancel(); + ValidForResume = true; + + this.MakeCurrent(); + + scheduledDifficultySwitch = Schedule(() => + { + Beatmap.Value = beatmapManager.GetWorkingBeatmap(nextBeatmap); + state = editorState; + + // This screen is a weird exception to the rule that nothing after song select changes the global beatmap. + // Because of this, we need to update the background stack's beatmap to match. + // If we don't do this, the editor will see a discrepancy and create a new background, along with an unnecessary transition. + ApplyToBackground(b => b.Beatmap = Beatmap.Value); + + pushEditor(); + }); + } + + private void pushEditor() + { + var editor = CreateEditor(); + + this.Push(editor); + + if (state != null) + editor.RestoreState(state); + + ValidForResume = false; + } + + public void CancelPendingDifficultySwitch() + { + scheduledDifficultySwitch?.Cancel(); + ValidForResume = false; + } + } +} diff --git a/osu.Game/Screens/Edit/EditorRoundedScreen.cs b/osu.Game/Screens/Edit/EditorRoundedScreen.cs index c6ced02021..b271a145f5 100644 --- a/osu.Game/Screens/Edit/EditorRoundedScreen.cs +++ b/osu.Game/Screens/Edit/EditorRoundedScreen.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Overlays; namespace osu.Game.Screens.Edit { @@ -17,9 +16,6 @@ namespace osu.Game.Screens.Edit [Resolved] private OsuColour colours { get; set; } - [Cached] - protected readonly OverlayColourProvider ColourProvider; - private Container roundedContent; protected override Container Content => roundedContent; @@ -27,7 +23,6 @@ namespace osu.Game.Screens.Edit public EditorRoundedScreen(EditorScreenMode mode) : base(mode) { - ColourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Edit/EditorScreen.cs b/osu.Game/Screens/Edit/EditorScreen.cs index 7fbb6a8ca0..2810f78835 100644 --- a/osu.Game/Screens/Edit/EditorScreen.cs +++ b/osu.Game/Screens/Edit/EditorScreen.cs @@ -4,17 +4,22 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Game.Overlays; namespace osu.Game.Screens.Edit { /// /// TODO: eventually make this inherit Screen and add a local screen stack inside the Editor. /// - public abstract class EditorScreen : Container + public abstract class EditorScreen : VisibilityContainer { [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } + [Cached] + protected readonly OverlayColourProvider ColourProvider; + protected override Container Content => content; private readonly Container content; @@ -28,16 +33,21 @@ namespace osu.Game.Screens.Edit Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; - InternalChild = content = new Container { RelativeSizeAxes = Axes.Both }; + ColourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + InternalChild = content = new PopoverContainer { RelativeSizeAxes = Axes.Both }; } - protected override void LoadComplete() + protected override void PopIn() { - base.LoadComplete(); + this.ScaleTo(1f, 200, Easing.OutQuint) + .FadeIn(200, Easing.OutQuint); + } - this.FadeTo(0) - .Then() - .FadeTo(1f, 250, Easing.OutQuint); + protected override void PopOut() + { + this.ScaleTo(0.98f, 200, Easing.OutQuint) + .FadeOut(200, Easing.OutQuint); } } } diff --git a/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs b/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs new file mode 100644 index 0000000000..decfa879a8 --- /dev/null +++ b/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Skinning; + +#nullable enable + +namespace osu.Game.Screens.Edit +{ + /// + /// A that fires when users have made a change to the beatmap skin + /// of the map being edited. + /// + public class EditorSkinProvidingContainer : RulesetSkinProvidingContainer + { + private readonly EditorBeatmapSkin? beatmapSkin; + + public EditorSkinProvidingContainer(EditorBeatmap editorBeatmap) + : base(editorBeatmap.PlayableBeatmap.BeatmapInfo.Ruleset.CreateInstance(), editorBeatmap.PlayableBeatmap, editorBeatmap.BeatmapSkin) + { + beatmapSkin = editorBeatmap.BeatmapSkin; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (beatmapSkin != null) + beatmapSkin.BeatmapSkinChanged += TriggerSourceChanged; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (beatmapSkin != null) + beatmapSkin.BeatmapSkinChanged -= TriggerSourceChanged; + } + } +} diff --git a/osu.Game/Screens/Edit/EditorState.cs b/osu.Game/Screens/Edit/EditorState.cs new file mode 100644 index 0000000000..4690074e3d --- /dev/null +++ b/osu.Game/Screens/Edit/EditorState.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +namespace osu.Game.Screens.Edit +{ + /// + /// Structure used to convey the general state of an instance. + /// + public class EditorState + { + /// + /// The current audio time. + /// + public double Time { get; set; } + + /// + /// The editor clipboard content. + /// + public string ClipboardContent { get; set; } = string.Empty; + } +} diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index 815f3ed0ea..ab8bd6a3bc 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -41,11 +43,11 @@ namespace osu.Game.Screens.Edit }); } - protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty); + protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? default); private class HeaderText : OsuSpriteText { - public HeaderText(string text) + public HeaderText(LocalisableString text) { Text = text.ToUpper(); Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold); diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index 6f92db98ee..d26856365e 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -118,7 +118,7 @@ namespace osu.Game.Screens.Edit protected override Track GetBeatmapTrack() => throw new NotImplementedException(); - protected override ISkin GetSkin() => throw new NotImplementedException(); + protected internal override ISkin GetSkin() => throw new NotImplementedException(); public override Stream GetStream(string storagePath) => throw new NotImplementedException(); } diff --git a/osu.Game/Screens/Edit/PromptForSaveDialog.cs b/osu.Game/Screens/Edit/PromptForSaveDialog.cs index 16504b47bd..e308a9533d 100644 --- a/osu.Game/Screens/Edit/PromptForSaveDialog.cs +++ b/osu.Game/Screens/Edit/PromptForSaveDialog.cs @@ -9,7 +9,7 @@ namespace osu.Game.Screens.Edit { public class PromptForSaveDialog : PopupDialog { - public PromptForSaveDialog(Action exit, Action saveAndExit) + public PromptForSaveDialog(Action exit, Action saveAndExit, Action cancel) { HeaderText = "Did you want to save your changes?"; @@ -30,6 +30,7 @@ namespace osu.Game.Screens.Edit new PopupDialogCancelButton { Text = @"Oops, continue editing", + Action = cancel }, }; } diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index 4a81959a54..05d9855a24 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Skinning; -using osuTK.Graphics; namespace osu.Game.Screens.Edit.Setup { @@ -30,9 +27,8 @@ namespace osu.Game.Screens.Edit.Setup } }; - var colours = Beatmap.BeatmapSkin?.GetConfig>(GlobalSkinColours.ComboColours)?.Value; - if (colours != null) - comboColours.Colours.AddRange(colours); + if (Beatmap.BeatmapSkin != null) + comboColours.Colours.BindTo(Beatmap.BeatmapSkin.ComboColours); } } } diff --git a/osu.Game/Screens/Edit/Setup/DesignSection.cs b/osu.Game/Screens/Edit/Setup/DesignSection.cs new file mode 100644 index 0000000000..d5d93db050 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/DesignSection.cs @@ -0,0 +1,129 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK; + +namespace osu.Game.Screens.Edit.Setup +{ + internal class DesignSection : SetupSection + { + protected LabelledSwitchButton EnableCountdown; + + protected FillFlowContainer CountdownSettings; + protected LabelledEnumDropdown CountdownSpeed; + protected LabelledNumberBox CountdownOffset; + + private LabelledSwitchButton widescreenSupport; + private LabelledSwitchButton epilepsyWarning; + private LabelledSwitchButton letterboxDuringBreaks; + private LabelledSwitchButton samplesMatchPlaybackRate; + + public override LocalisableString Title => "Design"; + + [BackgroundDependencyLoader] + private void load() + { + Children = new[] + { + EnableCountdown = new LabelledSwitchButton + { + Label = "Enable countdown", + Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None }, + Description = "If enabled, an \"Are you ready? 3, 2, 1, GO!\" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so." + }, + CountdownSettings = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + CountdownSpeed = new LabelledEnumDropdown + { + Label = "Countdown speed", + Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None ? Beatmap.BeatmapInfo.Countdown : CountdownType.Normal }, + Items = Enum.GetValues(typeof(CountdownType)).Cast().Where(type => type != CountdownType.None) + }, + CountdownOffset = new LabelledNumberBox + { + Label = "Countdown offset", + Current = { Value = Beatmap.BeatmapInfo.CountdownOffset.ToString() }, + Description = "If the countdown sounds off-time, use this to make it appear one or more beats early.", + } + } + }, + Empty(), + widescreenSupport = new LabelledSwitchButton + { + Label = "Widescreen support", + Description = "Allows storyboards to use the full screen space, rather than be confined to a 4:3 area.", + Current = { Value = Beatmap.BeatmapInfo.WidescreenStoryboard } + }, + epilepsyWarning = new LabelledSwitchButton + { + Label = "Epilepsy warning", + Description = "Recommended if the storyboard or video contain scenes with rapidly flashing colours.", + Current = { Value = Beatmap.BeatmapInfo.EpilepsyWarning } + }, + letterboxDuringBreaks = new LabelledSwitchButton + { + Label = "Letterbox during breaks", + Description = "Adds horizontal letterboxing to give a cinematic look during breaks.", + Current = { Value = Beatmap.BeatmapInfo.LetterboxInBreaks } + }, + samplesMatchPlaybackRate = new LabelledSwitchButton + { + Label = "Samples match playback rate", + Description = "When enabled, all samples will speed up or slow down when rate-changing mods are enabled.", + Current = { Value = Beatmap.BeatmapInfo.SamplesMatchPlaybackRate } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + EnableCountdown.Current.BindValueChanged(_ => updateCountdownSettingsVisibility(), true); + + EnableCountdown.Current.BindValueChanged(_ => updateBeatmap()); + CountdownSpeed.Current.BindValueChanged(_ => updateBeatmap()); + CountdownOffset.OnCommit += (_, __) => onOffsetCommitted(); + + widescreenSupport.Current.BindValueChanged(_ => updateBeatmap()); + epilepsyWarning.Current.BindValueChanged(_ => updateBeatmap()); + letterboxDuringBreaks.Current.BindValueChanged(_ => updateBeatmap()); + samplesMatchPlaybackRate.Current.BindValueChanged(_ => updateBeatmap()); + } + + private void updateCountdownSettingsVisibility() => CountdownSettings.FadeTo(EnableCountdown.Current.Value ? 1 : 0); + + private void onOffsetCommitted() + { + updateBeatmap(); + // update displayed text to ensure parsed value matches display (i.e. if empty string was provided). + CountdownOffset.Current.Value = Beatmap.BeatmapInfo.CountdownOffset.ToString(CultureInfo.InvariantCulture); + } + + private void updateBeatmap() + { + Beatmap.BeatmapInfo.Countdown = EnableCountdown.Current.Value ? CountdownSpeed.Current.Value : CountdownType.None; + Beatmap.BeatmapInfo.CountdownOffset = int.TryParse(CountdownOffset.Current.Value, NumberStyles.None, CultureInfo.InvariantCulture, out int offset) ? offset : 0; + + Beatmap.BeatmapInfo.WidescreenStoryboard = widescreenSupport.Current.Value; + Beatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning.Current.Value; + Beatmap.BeatmapInfo.LetterboxInBreaks = letterboxDuringBreaks.Current.Value; + Beatmap.BeatmapInfo.SamplesMatchPlaybackRate = samplesMatchPlaybackRate.Current.Value; + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index a8800d524f..75c6a89a66 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -30,7 +30,7 @@ namespace osu.Game.Screens.Edit.Setup Label = "Object Size", FixedLabelWidth = LABEL_WIDTH, Description = "The size of all hit objects", - Current = new BindableFloat(Beatmap.BeatmapInfo.BaseDifficulty.CircleSize) + Current = new BindableFloat(Beatmap.Difficulty.CircleSize) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, @@ -43,7 +43,7 @@ namespace osu.Game.Screens.Edit.Setup Label = "Health Drain", FixedLabelWidth = LABEL_WIDTH, Description = "The rate of passive health drain throughout playable time", - Current = new BindableFloat(Beatmap.BeatmapInfo.BaseDifficulty.DrainRate) + Current = new BindableFloat(Beatmap.Difficulty.DrainRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, @@ -56,7 +56,7 @@ namespace osu.Game.Screens.Edit.Setup Label = "Approach Rate", FixedLabelWidth = LABEL_WIDTH, Description = "The speed at which objects are presented to the player", - Current = new BindableFloat(Beatmap.BeatmapInfo.BaseDifficulty.ApproachRate) + Current = new BindableFloat(Beatmap.Difficulty.ApproachRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, @@ -69,7 +69,7 @@ namespace osu.Game.Screens.Edit.Setup 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) + Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, @@ -87,10 +87,10 @@ namespace osu.Game.Screens.Edit.Setup { // for now, update these on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. - Beatmap.BeatmapInfo.BaseDifficulty.CircleSize = circleSizeSlider.Current.Value; - Beatmap.BeatmapInfo.BaseDifficulty.DrainRate = healthDrainSlider.Current.Value; - Beatmap.BeatmapInfo.BaseDifficulty.ApproachRate = approachRateSlider.Current.Value; - Beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty = overallDifficultySlider.Current.Value; + Beatmap.Difficulty.CircleSize = circleSizeSlider.Current.Value; + Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; + Beatmap.Difficulty.ApproachRate = approachRateSlider.Current.Value; + Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; Beatmap.UpdateAllHitObjects(); } diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs index a33a70af65..f833bc49f7 100644 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -8,28 +8,28 @@ using System.Linq; using System.Threading.Tasks; 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.Cursor; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osuTK; namespace osu.Game.Screens.Edit.Setup { /// /// A labelled textbox which reveals an inline file chooser when clicked. /// - internal class FileChooserLabelledTextBox : LabelledTextBox, ICanAcceptFiles + internal class FileChooserLabelledTextBox : LabelledTextBox, ICanAcceptFiles, IHasPopover { private readonly string[] handledExtensions; - public IEnumerable HandledExtensions => handledExtensions; - /// - /// The target container to display the file chooser in. - /// - public Container Target; + public IEnumerable HandledExtensions => handledExtensions; private readonly Bindable currentFile = new Bindable(); @@ -51,23 +51,9 @@ namespace osu.Game.Screens.Edit.Setup Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, CornerRadius = CORNER_RADIUS, - OnFocused = DisplayFileChooser + OnFocused = this.ShowPopover }; - public void DisplayFileChooser() - { - FileSelector fileSelector; - - Target.Child = fileSelector = new FileSelector(currentFile.Value?.DirectoryName, handledExtensions) - { - RelativeSizeAxes = Axes.X, - Height = 400, - CurrentFile = { BindTarget = currentFile } - }; - - sectionsContainer.ScrollTo(fileSelector); - } - protected override void LoadComplete() { base.LoadComplete(); @@ -81,7 +67,7 @@ namespace osu.Game.Screens.Edit.Setup if (file.NewValue == null) return; - Target.Clear(); + this.HidePopover(); Current.Value = file.NewValue.FullName; } @@ -103,6 +89,13 @@ namespace osu.Game.Screens.Edit.Setup { public Action OnFocused; + protected override bool OnDragStart(DragStartEvent e) + { + // This text box is intended to be "read only" without actually specifying that. + // As such we don't want to allow the user to select its content with a drag. + return false; + } + protected override void OnFocus(FocusEvent e) { OnFocused?.Invoke(); @@ -111,5 +104,23 @@ namespace osu.Game.Screens.Edit.Setup GetContainingInputManager().TriggerFocusContention(this); } } + + public Popover GetPopover() => new FileChooserPopover(handledExtensions, currentFile); + + private class FileChooserPopover : OsuPopover + { + public FileChooserPopover(string[] handledExtensions, Bindable currentFile) + { + Child = new Container + { + Size = new Vector2(600, 400), + Child = new OsuFileSelector(currentFile.Value?.DirectoryName, handledExtensions) + { + RelativeSizeAxes = Axes.Both, + CurrentFile = { BindTarget = currentFile } + }, + }; + } + } } } diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index ba22c82ecc..8e739a786f 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -6,7 +6,6 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; @@ -39,62 +38,30 @@ namespace osu.Game.Screens.Edit.Setup [BackgroundDependencyLoader] private void load() { - Container audioTrackFileChooserContainer = createFileChooserContainer(); - Container backgroundFileChooserContainer = createFileChooserContainer(); - Children = new Drawable[] { - new FillFlowContainer + backgroundTextBox = new FileChooserLabelledTextBox(".jpg", ".jpeg", ".png") { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - 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, - TabbableContentContainer = this - }, - backgroundFileChooserContainer, - } + Label = "Background", + FixedLabelWidth = LABEL_WIDTH, + PlaceholderText = "Click to select a background image", + Current = { Value = working.Value.Metadata.BackgroundFile }, + TabbableContentContainer = this }, - new FillFlowContainer + audioTrackTextBox = new FileChooserLabelledTextBox(".mp3", ".ogg") { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - 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, - TabbableContentContainer = this - }, - audioTrackFileChooserContainer, - } - } + Label = "Audio Track", + FixedLabelWidth = LABEL_WIDTH, + PlaceholderText = "Click to select a track", + Current = { Value = working.Value.Metadata.AudioFile }, + TabbableContentContainer = this + }, }; backgroundTextBox.Current.BindValueChanged(backgroundChanged); audioTrackTextBox.Current.BindValueChanged(audioTrackChanged); } - private static Container createFileChooserContainer() => - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }; - public bool ChangeBackgroundImage(string path) { var info = new FileInfo(path); diff --git a/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs b/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs new file mode 100644 index 0000000000..935842ff99 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using osu.Game.Rulesets; + +namespace osu.Game.Screens.Edit.Setup +{ + public abstract class RulesetSetupSection : SetupSection + { + public sealed override LocalisableString Title => $"Ruleset ({rulesetInfo.Name})"; + + private readonly RulesetInfo rulesetInfo; + + protected RulesetSetupSection(RulesetInfo rulesetInfo) + { + this.rulesetInfo = rulesetInfo; + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs index 5bbec2574f..04767f1786 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreen.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.Containers; @@ -10,7 +11,7 @@ namespace osu.Game.Screens.Edit.Setup public class SetupScreen : EditorRoundedScreen { [Cached] - private SectionsContainer sections = new SectionsContainer(); + private SectionsContainer sections { get; } = new SetupScreenSectionsContainer(); [Cached] private SetupScreenHeader header = new SetupScreenHeader(); @@ -21,23 +22,41 @@ namespace osu.Game.Screens.Edit.Setup } [BackgroundDependencyLoader] - private void load() + private void load(EditorBeatmap beatmap) { - AddRange(new Drawable[] + var sectionsEnumerable = new List { - sections = new SectionsContainer - { - FixedHeader = header, - RelativeSizeAxes = Axes.Both, - Children = new SetupSection[] - { - new ResourcesSection(), - new MetadataSection(), - new DifficultySection(), - new ColoursSection() - } - }, - }); + new ResourcesSection(), + new MetadataSection(), + new DifficultySection(), + new ColoursSection(), + new DesignSection(), + }; + + var rulesetSpecificSection = beatmap.BeatmapInfo.Ruleset?.CreateInstance()?.CreateEditorSetupSection(); + if (rulesetSpecificSection != null) + sectionsEnumerable.Add(rulesetSpecificSection); + + Add(sections.With(s => + { + s.RelativeSizeAxes = Axes.Both; + s.ChildrenEnumerable = sectionsEnumerable; + s.FixedHeader = header; + })); + } + + private class SetupScreenSectionsContainer : SectionsContainer + { + protected override UserTrackingScrollContainer CreateScrollContainer() + { + var scrollContainer = base.CreateScrollContainer(); + + // Workaround for masking issues (see https://github.com/ppy/osu-framework/issues/1675#issuecomment-910023157) + // Note that this actually causes the full scroll range to be reduced by 2px at the bottom, but it's not really noticeable. + scrollContainer.Margin = new MarginPadding { Top = 2 }; + + return scrollContainer; + } } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupSection.cs b/osu.Game/Screens/Edit/Setup/SetupSection.cs index 1f988d62e2..1dde6fb926 100644 --- a/osu.Game/Screens/Edit/Setup/SetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/SetupSection.cs @@ -12,9 +12,9 @@ using osuTK; namespace osu.Game.Screens.Edit.Setup { - internal abstract class SetupSection : Container + public abstract class SetupSection : Container { - private readonly FillFlowContainer flow; + private FillFlowContainer flow; /// /// Used to align some of the child s together to achieve a grid-like look. @@ -31,7 +31,8 @@ namespace osu.Game.Screens.Edit.Setup public abstract LocalisableString Title { get; } - protected SetupSection() + [BackgroundDependencyLoader] + private void load() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Screens/Edit/Timing/SampleSection.cs b/osu.Game/Screens/Edit/Timing/SampleSection.cs index cc73af6349..52709a2bbe 100644 --- a/osu.Game/Screens/Edit/Timing/SampleSection.cs +++ b/osu.Game/Screens/Edit/Timing/SampleSection.cs @@ -42,15 +42,6 @@ namespace osu.Game.Screens.Edit.Timing } } - protected override SampleControlPoint CreatePoint() - { - var reference = Beatmap.ControlPointInfo.SamplePointAt(SelectedGroup.Value.Time); - - return new SampleControlPoint - { - SampleBank = reference.SampleBank, - SampleVolume = reference.SampleVolume, - }; - } + protected override SampleControlPoint CreatePoint() => new SampleControlPoint(); // TODO: remove } } diff --git a/osu.Game/Screens/Edit/Verify/IssueTable.cs b/osu.Game/Screens/Edit/Verify/IssueTable.cs index 05a8fdd26d..b38a62d838 100644 --- a/osu.Game/Screens/Edit/Verify/IssueTable.cs +++ b/osu.Game/Screens/Edit/Verify/IssueTable.cs @@ -8,6 +8,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; @@ -52,7 +53,7 @@ namespace osu.Game.Screens.Edit.Verify if (issue.Time != null) { clock.Seek(issue.Time.Value); - editor.OnPressed(GlobalAction.EditorComposeMode); + editor.OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, GlobalAction.EditorComposeMode)); } if (!issue.HitObjects.Any()) diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 0434135547..910a0c7d61 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -59,9 +59,10 @@ namespace osu.Game.Screens Bindable Ruleset { get; } /// - /// Whether mod rate adjustments are allowed to be applied. + /// Whether mod track adjustments should be applied on entering this screen. + /// A value means that the parent screen's value of this setting will be used. /// - bool AllowRateAdjustments { get; } + bool? AllowTrackAdjustments { get; } /// /// Invoked when the back button has been pressed to close any overlays before exiting this . 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/Loader.cs b/osu.Game/Screens/Loader.cs index 0bfabdaa15..41097a4c74 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -49,14 +49,16 @@ namespace osu.Game.Screens switch (introSequence) { case IntroSequence.Circles: - return new IntroCircles(); + return new IntroCircles(createMainMenu); case IntroSequence.Welcome: - return new IntroWelcome(); + return new IntroWelcome(createMainMenu); default: - return new IntroTriangles(); + return new IntroTriangles(createMainMenu); } + + MainMenu createMainMenu() => new MainMenu(); } protected virtual ShaderPrecompiler CreateShaderPrecompiler() => new ShaderPrecompiler(); diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 38290a6530..5f76176aab 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -210,7 +210,7 @@ namespace osu.Game.Screens.Menu { if (buttonsTopLevel.Any(b => e.Key == b.TriggerKey)) { - logo?.Click(); + logo?.TriggerClick(); return true; } } @@ -218,15 +218,15 @@ namespace osu.Game.Screens.Menu return base.OnKeyDown(e); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Back: return goBack(); case GlobalAction.Select: - logo?.Click(); + logo?.TriggerClick(); return true; default: @@ -234,7 +234,7 @@ namespace osu.Game.Screens.Menu } } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -248,7 +248,7 @@ namespace osu.Game.Screens.Menu return true; case ButtonSystemState.Play: - backButton.Click(); + backButton.TriggerClick(); return true; default: @@ -268,11 +268,11 @@ namespace osu.Game.Screens.Menu return true; case ButtonSystemState.TopLevel: - buttonsTopLevel.First().Click(); + buttonsTopLevel.First().TriggerClick(); return false; case ButtonSystemState.Play: - buttonsPlay.First().Click(); + buttonsPlay.First().TriggerClick(); return false; } } @@ -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 a71b53157a..7f34e1e395 100644 --- a/osu.Game/Screens/Menu/Disclaimer.cs +++ b/osu.Game/Screens/Menu/Disclaimer.cs @@ -181,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) diff --git a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs b/osu.Game/Screens/Menu/ExitConfirmOverlay.cs index a491283e5f..364da2f887 100644 --- a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs +++ b/osu.Game/Screens/Menu/ExitConfirmOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Overlays; @@ -18,9 +19,9 @@ namespace osu.Game.Screens.Menu { } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action == GlobalAction.Back) + if (e.Action == GlobalAction.Back) { BeginConfirm(); return true; @@ -29,9 +30,9 @@ namespace osu.Game.Screens.Menu return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - if (action == GlobalAction.Back) + if (e.Action == GlobalAction.Back) { if (!Fired) AbortConfirm(); diff --git a/osu.Game/Screens/Menu/IntroCircles.cs b/osu.Game/Screens/Menu/IntroCircles.cs index a1b8c3a203..2792d05f75 100644 --- a/osu.Game/Screens/Menu/IntroCircles.cs +++ b/osu.Game/Screens/Menu/IntroCircles.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.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -20,6 +22,11 @@ namespace osu.Game.Screens.Menu private Sample welcome; + public IntroCircles([CanBeNull] Func createNextScreen = null) + : base(createNextScreen) + { + } + [BackgroundDependencyLoader] private void load(AudioManager audio) { diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index 71f3b60026..32fb9f1d6d 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -1,14 +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.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Utils; using osu.Framework.Screens; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.IO.Archives; @@ -54,7 +57,7 @@ namespace osu.Game.Screens.Menu private LeasedBindable beatmap; - private MainMenu mainMenu; + private OsuScreen nextScreen; [Resolved] private AudioManager audio { get; set; } @@ -62,12 +65,20 @@ namespace osu.Game.Screens.Menu [Resolved] private MusicController musicController { get; set; } + [CanBeNull] + private readonly Func createNextScreen; + /// /// Whether the is provided by osu! resources, rather than a user beatmap. /// Only valid during or after . /// protected bool UsingThemedIntro { get; private set; } + protected IntroScreen([CanBeNull] Func createNextScreen = null) + { + this.createNextScreen = createNextScreen; + } + [BackgroundDependencyLoader] private void load(OsuConfigManager config, SkinManager skinManager, BeatmapManager beatmaps, Framework.Game game) { @@ -100,8 +111,12 @@ namespace osu.Game.Screens.Menu // if we detect that the theme track or beatmap is unavailable this is either first startup or things are in a bad state. // this could happen if a user has nuked their files store. for now, reimport to repair this. var import = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream($"Tracks/{BeatmapFile}"), BeatmapFile)).Result; - import.Protected = true; - beatmaps.Update(import); + + import.PerformWrite(b => + { + b.Protected = true; + beatmaps.Update(b); + }); loadThemedIntro(); } @@ -109,12 +124,14 @@ namespace osu.Game.Screens.Menu bool loadThemedIntro() { - setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == BeatmapHash); + setInfo = beatmaps.QueryBeatmapSets(b => b.Hash == BeatmapHash, IncludedDetails.AllButRuleset).FirstOrDefault(); if (setInfo == null) return false; - return (initialBeatmap = beatmaps.GetWorkingBeatmap(setInfo.Beatmaps[0])) != null; + initialBeatmap = beatmaps.GetWorkingBeatmap(setInfo.Beatmaps[0]); + + return UsingThemedIntro = initialBeatmap != null; } } @@ -164,7 +181,7 @@ namespace osu.Game.Screens.Menu protected override BackgroundScreen CreateBackground() => new BackgroundScreenBlack(); - protected void StartTrack() + protected virtual void StartTrack() { // Only start the current track if it is the menu music. A beatmap's track is started when entering the Main Menu. if (UsingThemedIntro) @@ -183,7 +200,6 @@ namespace osu.Game.Screens.Menu { beatmap.Value = initialBeatmap; Track = initialBeatmap.Track; - UsingThemedIntro = !initialBeatmap.Track.IsDummyDevice; // ensure the track starts at maximum volume musicController.CurrentTrack.FinishTransforms(); @@ -208,14 +224,21 @@ namespace osu.Game.Screens.Menu } } - protected void PrepareMenuLoad() => LoadComponentAsync(mainMenu = new MainMenu()); + protected void PrepareMenuLoad() + { + nextScreen = createNextScreen?.Invoke(); + + if (nextScreen != null) + LoadComponentAsync(nextScreen); + } protected void LoadMenu() { beatmap.Return(); DidLoadMenu = true; - this.Push(mainMenu); + if (nextScreen != null) + this.Push(nextScreen); } } } 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..48ced63182 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -41,6 +42,14 @@ namespace osu.Game.Screens.Menu private Sample welcome; + private DecoupleableInterpolatingFramedClock decoupledClock; + private TrianglesIntroSequence intro; + + public IntroTriangles([CanBeNull] Func createNextScreen = null) + : base(createNextScreen) + { + } + [BackgroundDependencyLoader] private void load() { @@ -56,10 +65,18 @@ namespace osu.Game.Screens.Menu { PrepareMenuLoad(); - LoadComponentAsync(new TrianglesIntroSequence(logo, background) + decoupledClock = new DecoupleableInterpolatingFramedClock + { + IsCoupled = false + }; + + if (UsingThemedIntro) + decoupledClock.ChangeSource(Track); + + LoadComponentAsync(intro = new TrianglesIntroSequence(logo, background) { RelativeSizeAxes = Axes.Both, - Clock = new FramedClock(UsingThemedIntro ? Track : null), + Clock = decoupledClock, LoadMenu = LoadMenu }, t => { @@ -72,12 +89,25 @@ namespace osu.Game.Screens.Menu } } + public override void OnSuspending(IScreen next) + { + base.OnSuspending(next); + + // important as there is a clock attached to a track which will likely be disposed before returning to this screen. + intro.Expire(); + } + public override void OnResuming(IScreen last) { base.OnResuming(last); background.FadeOut(100); } + protected override void StartTrack() + { + decoupledClock.Start(); + } + private class TrianglesIntroSequence : CompositeDrawable { private readonly OsuLogo logo; @@ -172,27 +202,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 +230,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 +253,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..639591cfef 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.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 osuTK; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -32,6 +34,11 @@ namespace osu.Game.Screens.Menu private BackgroundScreenDefault background; + public IntroWelcome([CanBeNull] Func createNextScreen = null) + : base(createNextScreen) + { + } + [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -143,7 +150,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/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index e53b46f391..221b31f855 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -36,8 +36,6 @@ namespace osu.Game.Screens.Menu public override bool AllowExternalScreenChange => true; - public override bool AllowRateAdjustments => false; - private Screen songSelect; private MenuSideFlashes sideFlashes; @@ -103,7 +101,7 @@ namespace osu.Game.Screens.Menu OnEdit = delegate { Beatmap.SetDefault(); - this.Push(new Editor()); + this.Push(new EditorLoader()); }, OnSolo = loadSoloSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), 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/DisableableTabControl.cs b/osu.Game/Screens/OnlinePlay/Components/DisableableTabControl.cs index bbc407e926..2b596da361 100644 --- a/osu.Game/Screens/OnlinePlay/Components/DisableableTabControl.cs +++ b/osu.Game/Screens/OnlinePlay/Components/DisableableTabControl.cs @@ -9,7 +9,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { public abstract class DisableableTabControl : TabControl { - public readonly BindableBool Enabled = new BindableBool(); + public readonly BindableBool Enabled = new BindableBool(true); protected override void AddTabItem(TabItem tab, bool addToDropdown = true) { diff --git a/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs b/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs index ae1ca1b967..613f16563c 100644 --- a/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs +++ b/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs @@ -2,24 +2,28 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Online.Rooms; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Components { public class DrawableGameType : CircularContainer, IHasTooltip { - private readonly GameType type; + private readonly MatchType type; - public LocalisableString TooltipText => type.Name; + public LocalisableString TooltipText => type.GetLocalisableDescription(); - public DrawableGameType(GameType type) + public DrawableGameType(MatchType type) { this.type = type; Masking = true; @@ -34,10 +38,138 @@ namespace osu.Game.Screens.OnlinePlay.Components }; } + [Resolved] + private OsuColour colours { get; set; } + [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - Add(type.GetIcon(colours, Height / 2)); + Add(getIconFor(type)); + } + + private Drawable getIconFor(MatchType matchType) + { + float size = Height / 2; + + switch (matchType) + { + default: + case MatchType.Playlists: + return new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(size), + Icon = FontAwesome.Regular.Clock, + Colour = colours.Blue, + Shadow = false + }; + + case MatchType.HeadToHead: + return new VersusRow(colours.Blue, colours.Blue, size * 0.6f) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + + case MatchType.TeamVersus: + return new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2f), + Children = new[] + { + new VersusRow(colours.Blue, colours.Pink, size * 0.5f), + new VersusRow(colours.Blue, colours.Pink, size * 0.5f), + }, + }; + + // case MatchType.TagCoop: + // return new SpriteIcon + // { + // Anchor = Anchor.Centre, + // Origin = Anchor.Centre, + // Size = new Vector2(size), + // Icon = FontAwesome.Solid.Sync, + // Colour = colours.Blue, + // + // Shadow = false + // }; + + // case MatchType.TagTeamCoop: + // return new FillFlowContainer + // { + // Anchor = Anchor.Centre, + // Origin = Anchor.Centre, + // AutoSizeAxes = Axes.Both, + // Direction = FillDirection.Horizontal, + // Spacing = new Vector2(2f), + // Children = new[] + // { + // new SpriteIcon + // { + // Icon = FontAwesome.Solid.Sync, + // Size = new Vector2(size * 0.75f), + // Colour = colours.Blue, + // Shadow = false, + // }, + // new SpriteIcon + // { + // Icon = FontAwesome.Solid.Sync, + // Size = new Vector2(size * 0.75f), + // Colour = colours.Pink, + // Shadow = false, + // }, + // }, + // }; + } + } + + private class VersusRow : FillFlowContainer + { + public VersusRow(Color4 first, Color4 second, float size) + { + var triangleSize = new Vector2(size); + AutoSizeAxes = Axes.Both; + Spacing = new Vector2(2f, 0f); + + Children = new[] + { + new Container + { + Size = triangleSize, + Colour = first, + Children = new[] + { + new EquilateralTriangle + { + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Rotation = 90, + EdgeSmoothness = new Vector2(1f), + }, + }, + }, + new Container + { + Size = triangleSize, + Colour = second, + Children = new[] + { + new EquilateralTriangle + { + Anchor = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Rotation = -90, + EdgeSmoothness = new Vector2(1f), + }, + }, + }, + }; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs index e50784fcbe..fcf7767958 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.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 System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,18 +15,19 @@ namespace osu.Game.Screens.OnlinePlay.Components /// public class ListingPollingComponent : RoomPollingComponent { - [Resolved] - private Bindable currentFilter { get; set; } + public IBindable InitialRoomsReceived => initialRoomsReceived; + private readonly Bindable initialRoomsReceived = new Bindable(); - [Resolved] - private Bindable selectedRoom { get; set; } + public readonly Bindable Filter = new Bindable(); [BackgroundDependencyLoader] private void load() { - currentFilter.BindValueChanged(_ => + Filter.BindValueChanged(_ => { - NotifyRoomsReceived(null); + RoomManager.ClearRooms(); + initialRoomsReceived.Value = false; + if (IsLoaded) PollImmediately(); }); @@ -38,24 +40,29 @@ namespace osu.Game.Screens.OnlinePlay.Components if (!API.IsLoggedIn) return base.Poll(); + if (Filter.Value == null) + return base.Poll(); + var tcs = new TaskCompletionSource(); pollReq?.Cancel(); - pollReq = new GetRoomsRequest(currentFilter.Value.Status, currentFilter.Value.Category); + pollReq = new GetRoomsRequest(Filter.Value.Status, Filter.Value.Category); pollReq.Success += result => { - for (int i = 0; i < result.Count; i++) + foreach (var existing in RoomManager.Rooms.ToArray()) { - if (result[i].RoomID.Value == selectedRoom.Value?.RoomID.Value) - { - // The listing request always has less information than the opened room, so don't include it. - result[i] = selectedRoom.Value; - break; - } + if (result.All(r => r.RoomID.Value != existing.RoomID.Value)) + RoomManager.RemoveRoom(existing); } - NotifyRoomsReceived(result); + foreach (var incoming in result) + { + incoming.RemoveExpiredPlaylistItems(); + RoomManager.AddOrUpdateRoom(incoming); + } + + initialRoomsReceived.Value = true; tcs.SetResult(true); }; diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs new file mode 100644 index 0000000000..6ce5f6a6db --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; +using osu.Game.Online.Rooms; +using osuTK; +using osuTK.Graphics; + +#nullable enable + +namespace osu.Game.Screens.OnlinePlay.Components +{ + public abstract class OnlinePlayBackgroundScreen : BackgroundScreen + { + private CancellationTokenSource? cancellationSource; + private PlaylistItemBackground? background; + + protected OnlinePlayBackgroundScreen() + : base(false) + { + AddInternal(new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MinValue, + Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.9f), Color4.Black.Opacity(0.6f)) + }); + } + + [BackgroundDependencyLoader] + private void load() + { + switchBackground(new PlaylistItemBackground(playlistItem)); + } + + private PlaylistItem? playlistItem; + + protected PlaylistItem? PlaylistItem + { + get => playlistItem; + set + { + if (playlistItem == value) + return; + + playlistItem = value; + + if (LoadState > LoadState.Ready) + updateBackground(); + } + } + + private void updateBackground() + { + Schedule(() => + { + var beatmap = playlistItem?.Beatmap.Value; + + if (background?.BeatmapInfo?.BeatmapSet?.OnlineInfo?.Covers?.Cover == beatmap?.BeatmapSet?.OnlineInfo?.Covers?.Cover) + return; + + cancellationSource?.Cancel(); + LoadComponentAsync(new PlaylistItemBackground(playlistItem), switchBackground, (cancellationSource = new CancellationTokenSource()).Token); + }); + } + + private void switchBackground(PlaylistItemBackground newBackground) + { + float newDepth = 0; + + if (background != null) + { + newDepth = background.Depth + 1; + background.FinishTransforms(); + background.FadeOut(250); + background.Expire(); + } + + newBackground.Depth = newDepth; + newBackground.BlurTo(new Vector2(10)); + + AddInternal(background = newBackground); + } + + public override void OnSuspending(IScreen next) + { + base.OnSuspending(next); + this.MoveToX(0, TRANSITION_LENGTH); + } + + public override bool OnExiting(IScreen next) + { + var result = base.OnExiting(next); + this.MoveToX(0); + return result; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs index d8dfac496d..e2ba0b03b0 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs @@ -10,12 +10,12 @@ namespace osu.Game.Screens.OnlinePlay.Components { public class OnlinePlayBackgroundSprite : OnlinePlayComposite { - private readonly BeatmapSetCoverType beatmapSetCoverType; + protected readonly BeatmapSetCoverType BeatmapSetCoverType; private UpdateableBeatmapBackgroundSprite sprite; public OnlinePlayBackgroundSprite(BeatmapSetCoverType beatmapSetCoverType = BeatmapSetCoverType.Cover) { - this.beatmapSetCoverType = beatmapSetCoverType; + BeatmapSetCoverType = beatmapSetCoverType; } [BackgroundDependencyLoader] @@ -33,6 +33,6 @@ namespace osu.Game.Screens.OnlinePlay.Components sprite.Beatmap.Value = Playlist.FirstOrDefault()?.Beatmap.Value; } - protected virtual UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new UpdateableBeatmapBackgroundSprite(beatmapSetCoverType) { RelativeSizeAxes = Axes.Both }; + protected virtual UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new UpdateableBeatmapBackgroundSprite(BeatmapSetCoverType) { RelativeSizeAxes = Axes.Both }; } } diff --git a/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs b/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs new file mode 100644 index 0000000000..90ad6e0f6e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.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. + +#nullable enable + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Components +{ + public class PlaylistItemBackground : Background + { + public readonly BeatmapInfo? BeatmapInfo; + + public PlaylistItemBackground(PlaylistItem? playlistItem) + { + BeatmapInfo = playlistItem?.Beatmap.Value; + } + + [BackgroundDependencyLoader] + private void load(BeatmapManager beatmaps, LargeTextureStore textures) + { + Texture? texture = null; + + // prefer online cover where available. + if (BeatmapInfo?.BeatmapSet?.OnlineInfo?.Covers.Cover != null) + texture = textures.Get(BeatmapInfo.BeatmapSet.OnlineInfo.Covers.Cover); + + Sprite.Texture = texture ?? beatmaps.DefaultBeatmap.Background; + } + + public override bool Equals(Background? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return other.GetType() == GetType() + && ((PlaylistItemBackground)other).BeatmapInfo == BeatmapInfo; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs index 227a772b2d..97377278a6 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs @@ -8,7 +8,6 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Online.API; @@ -17,15 +16,12 @@ using osu.Game.Rulesets; namespace osu.Game.Screens.OnlinePlay.Components { - public abstract class RoomManager : CompositeDrawable, IRoomManager + public class RoomManager : Component, IRoomManager { public event Action RoomsUpdated; private readonly BindableList rooms = new BindableList(); - public IBindable InitialRoomsReceived => initialRoomsReceived; - private readonly Bindable initialRoomsReceived = new Bindable(); - public IBindableList Rooms => rooms; protected IBindable JoinedRoom => joinedRoom; @@ -40,15 +36,9 @@ namespace osu.Game.Screens.OnlinePlay.Components [Resolved] private IAPIProvider api { get; set; } - protected RoomManager() + public RoomManager() { RelativeSizeAxes = Axes.Both; - - InternalChildren = CreatePollingComponents().Select(p => - { - p.RoomsReceived = onRoomsReceived; - return p; - }).ToList(); } protected override void Dispose(bool isDisposing) @@ -67,16 +57,16 @@ namespace osu.Game.Screens.OnlinePlay.Components { joinedRoom.Value = room; - update(room, result); - addRoom(room); + AddOrUpdateRoom(result); + room.CopyFrom(result); // Also copy back to the source model, since this is likely to have been stored elsewhere. - RoomsUpdated?.Invoke(); + // The server may not contain all properties (such as password), so invoke success with the given room. onSuccess?.Invoke(room); }; req.Failure += exception => { - onError?.Invoke(req.Result?.Error ?? exception.Message); + onError?.Invoke(req.Response?.Error ?? exception.Message); }; api.Queue(req); @@ -84,10 +74,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 += () => { @@ -97,9 +87,10 @@ namespace osu.Game.Screens.OnlinePlay.Components currentJoinRoomRequest.Failure += exception => { - if (!(exception is OperationCanceledException)) - Logger.Log($"Failed to join room: {exception}", level: LogLevel.Important); - onError?.Invoke(exception.ToString()); + if (exception is OperationCanceledException) + return; + + onError?.Invoke(exception.Message); }; api.Queue(currentJoinRoomRequest); @@ -118,84 +109,47 @@ namespace osu.Game.Screens.OnlinePlay.Components private readonly HashSet ignoredRooms = new HashSet(); - private void onRoomsReceived(List received) + public void AddOrUpdateRoom(Room room) { - if (received == null) - { - ClearRooms(); + Debug.Assert(room.RoomID.Value != null); + + if (ignoredRooms.Contains(room.RoomID.Value.Value)) return; - } - // Remove past matches - foreach (var r in rooms.ToList()) + try { - if (received.All(e => e.RoomID.Value != r.RoomID.Value)) - rooms.Remove(r); - } + foreach (var pi in room.Playlist) + pi.MapObjects(beatmaps, rulesets); - for (int i = 0; i < received.Count; i++) + var existing = rooms.FirstOrDefault(e => e.RoomID.Value == room.RoomID.Value); + if (existing == null) + rooms.Add(room); + else + existing.CopyFrom(room); + } + catch (Exception ex) { - var room = received[i]; + Logger.Error(ex, $"Failed to update room: {room.Name.Value}."); - Debug.Assert(room.RoomID.Value != null); - - if (ignoredRooms.Contains(room.RoomID.Value.Value)) - continue; - - room.Position.Value = i; - - try - { - update(room, room); - addRoom(room); - } - catch (Exception ex) - { - Logger.Error(ex, $"Failed to update room: {room.Name.Value}."); - - ignoredRooms.Add(room.RoomID.Value.Value); - rooms.Remove(room); - } + ignoredRooms.Add(room.RoomID.Value.Value); + rooms.Remove(room); } - RoomsUpdated?.Invoke(); - initialRoomsReceived.Value = true; + notifyRoomsUpdated(); } - protected void RemoveRoom(Room room) => rooms.Remove(room); + public void RemoveRoom(Room room) + { + rooms.Remove(room); + notifyRoomsUpdated(); + } - protected void ClearRooms() + public void ClearRooms() { rooms.Clear(); - initialRoomsReceived.Value = false; + notifyRoomsUpdated(); } - /// - /// Updates a local with a remote copy. - /// - /// The local to update. - /// The remote to update with. - private void update(Room local, Room remote) - { - foreach (var pi in remote.Playlist) - pi.MapObjects(beatmaps, rulesets); - - local.CopyFrom(remote); - } - - /// - /// Adds a to the list of available rooms. - /// - /// The to add. - private void addRoom(Room room) - { - var existing = rooms.FirstOrDefault(e => e.RoomID.Value == room.RoomID.Value); - if (existing == null) - rooms.Add(room); - else - existing.CopyFrom(room); - } - - protected abstract IEnumerable CreatePollingComponents(); + private void notifyRoomsUpdated() => Scheduler.AddOnce(() => RoomsUpdated?.Invoke()); } } diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs index b2ea3a05d6..cd224a7347 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs @@ -1,29 +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.Allocation; using osu.Game.Online; using osu.Game.Online.API; -using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { public abstract class RoomPollingComponent : PollingComponent { - /// - /// Invoked when any s have been received from the API. - /// - /// Any s present locally but not returned by this event are to be removed from display. - /// If null, the display of local rooms is reset to an initial state. - /// - /// - public Action> RoomsReceived; - [Resolved] protected IAPIProvider API { get; private set; } - protected void NotifyRoomsReceived(List rooms) => RoomsReceived?.Invoke(rooms); + [Resolved] + protected IRoomManager RoomManager { get; private set; } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomStatusInfo.cs b/osu.Game/Screens/OnlinePlay/Components/RoomStatusInfo.cs deleted file mode 100644 index bcc256bcff..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/RoomStatusInfo.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public class RoomStatusInfo : OnlinePlayComposite - { - public RoomStatusInfo() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - StatusPart statusPart; - EndDatePart endDatePart; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - statusPart = new StatusPart - { - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14) - }, - endDatePart = new EndDatePart { Font = OsuFont.GetFont(size: 14) } - } - }; - - statusPart.EndDate.BindTo(EndDate); - statusPart.Status.BindTo(Status); - statusPart.Availability.BindTo(Availability); - endDatePart.EndDate.BindTo(EndDate); - } - - private class EndDatePart : DrawableDate - { - public readonly IBindable EndDate = new Bindable(); - - public EndDatePart() - : base(DateTimeOffset.UtcNow) - { - EndDate.BindValueChanged(date => - { - // If null, set a very large future date to prevent unnecessary schedules. - Date = date.NewValue ?? DateTimeOffset.Now.AddYears(1); - }, true); - } - - protected override string Format() - { - if (EndDate.Value == null) - return string.Empty; - - var diffToNow = Date.Subtract(DateTimeOffset.Now); - - if (diffToNow.TotalSeconds < -5) - return $"Closed {base.Format()}"; - - if (diffToNow.TotalSeconds < 0) - return "Closed"; - - if (diffToNow.TotalSeconds < 5) - return "Closing soon"; - - return $"Closing {base.Format()}"; - } - } - - private class StatusPart : EndDatePart - { - public readonly IBindable Status = new Bindable(); - public readonly IBindable Availability = new Bindable(); - - [Resolved] - private OsuColour colours { get; set; } - - public StatusPart() - { - EndDate.BindValueChanged(_ => Format()); - Status.BindValueChanged(_ => Format()); - Availability.BindValueChanged(_ => Format()); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Text = Format(); - } - - protected override string Format() - { - if (!IsLoaded) - return string.Empty; - - RoomStatus status = Date < DateTimeOffset.Now ? new RoomStatusEnded() : Status.Value ?? new RoomStatusOpen(); - - this.FadeColour(status.GetAppropriateColour(colours), 100); - return $"{Availability.Value.GetDescription()}, {status.Message}"; - } - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs index dcf3c94b76..b9d2bdf23e 100644 --- a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs @@ -1,11 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components @@ -15,20 +12,14 @@ namespace osu.Game.Screens.OnlinePlay.Components /// public class SelectionPollingComponent : RoomPollingComponent { - [Resolved] - private Bindable selectedRoom { get; set; } - [Resolved] private IRoomManager roomManager { get; set; } - [BackgroundDependencyLoader] - private void load() + private readonly Room room; + + public SelectionPollingComponent(Room room) { - selectedRoom.BindValueChanged(_ => - { - if (IsLoaded) - PollImmediately(); - }); + this.room = room; } private GetRoomRequest pollReq; @@ -38,27 +29,18 @@ namespace osu.Game.Screens.OnlinePlay.Components if (!API.IsLoggedIn) return base.Poll(); - if (selectedRoom.Value?.RoomID.Value == null) + if (room.RoomID.Value == null) return base.Poll(); var tcs = new TaskCompletionSource(); pollReq?.Cancel(); - pollReq = new GetRoomRequest(selectedRoom.Value.RoomID.Value.Value); + pollReq = new GetRoomRequest(room.RoomID.Value.Value); pollReq.Success += result => { - // existing rooms need to be ordered by their position because the received of NotifyRoomsReceives expects to be able to sort them based on this order. - var rooms = new List(roomManager.Rooms.OrderBy(r => r.Position.Value)); - - int index = rooms.FindIndex(r => r.RoomID.Value == result.RoomID.Value); - - if (index < 0) - return; - - rooms[index] = result; - - NotifyRoomsReceived(rooms); + result.RemoveExpiredPlaylistItems(); + RoomManager.AddOrUpdateRoom(result); tcs.SetResult(true); }; diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index b2e35d7020..7b14acf924 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -1,15 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; -using osu.Game.Screens.Ranking.Expanded; using osuTK; namespace osu.Game.Screens.OnlinePlay.Components @@ -62,8 +64,8 @@ namespace osu.Game.Screens.OnlinePlay.Components AutoSizeAxes = Axes.Both, Children = new Drawable[] { - minDisplay = new StarRatingDisplay(default), - maxDisplay = new StarRatingDisplay(default) + minDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range), + maxDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Range) } } }; @@ -85,9 +87,10 @@ namespace osu.Game.Screens.OnlinePlay.Components minDisplay.Current.Value = minDifficulty; maxDisplay.Current.Value = maxDifficulty; + maxDisplay.Alpha = Precision.AlmostEquals(Math.Round(minDifficulty.Stars, 2), Math.Round(maxDifficulty.Stars, 2)) ? 0 : 1; - minBackground.Colour = colours.ForDifficultyRating(minDifficulty.DifficultyRating, true); - maxBackground.Colour = colours.ForDifficultyRating(maxDifficulty.DifficultyRating, true); + minBackground.Colour = colours.ForStarDifficulty(minDifficulty.Stars); + maxBackground.Colour = colours.ForStarDifficulty(maxDifficulty.Stars); } } } diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index a3a61ccc36..585b024623 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -108,7 +108,11 @@ namespace osu.Game.Screens.OnlinePlay difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value, requiredMods) { Size = new Vector2(32) }; beatmapText.Clear(); - beatmapText.AddLink(Item.Beatmap.Value.ToRomanisableString(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString()); + beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString(), null, text => + { + text.Truncate = true; + text.RelativeSizeAxes = Axes.X; + }); authorText.Clear(); @@ -147,84 +151,96 @@ namespace osu.Game.Screens.OnlinePlay RelativeSizeAxes = Axes.Both, Beatmap = { BindTarget = beatmap } }, - new FillFlowContainer + new GridContainer { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 8 }, - Spacing = new Vector2(8, 0), - Direction = FillDirection.Horizontal, - Children = new Drawable[] + ColumnDimensions = new[] { - difficultyIconContainer = new Container + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + difficultyIconContainer = new Container { - beatmapText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, - new FillFlowContainer + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Left = 8, Right = 8, }, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10f, 0), - Children = new Drawable[] + beatmapText = new LinkFlowContainer(fontParameters) { - new FillFlowContainer + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10f, 0), + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10f, 0), - Children = new Drawable[] + new FillFlowContainer { - authorText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, - explicitContentPill = new ExplicitContentBeatmapPill + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10f, 0), + Children = new Drawable[] { - Alpha = 0f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Top = 3f }, - } + authorText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, + explicitContentPill = new ExplicitContentBeatmapPill + { + Alpha = 0f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Top = 3f }, + } + }, }, - }, - new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Child = modDisplay = new ModDisplay + new Container { - Scale = new Vector2(0.4f), - ExpansionMode = ExpansionMode.AlwaysExpanded + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Child = modDisplay = new ModDisplay + { + Scale = new Vector2(0.4f), + ExpansionMode = ExpansionMode.AlwaysExpanded + } } } } } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = 8, Right = 10, }, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + ChildrenEnumerable = CreateButtons().Select(button => button.With(b => + { + b.Anchor = Anchor.Centre; + b.Origin = Anchor.Centre; + })) } } } }, - new FillFlowContainer - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5), - X = -10, - ChildrenEnumerable = CreateButtons().Select(button => button.With(b => - { - b.Anchor = Anchor.Centre; - b.Origin = Anchor.Centre; - })) - } } }; } diff --git a/osu.Game/Screens/OnlinePlay/Header.cs b/osu.Game/Screens/OnlinePlay/Header.cs index bf0a53cbb6..2d4b5cc527 100644 --- a/osu.Game/Screens/OnlinePlay/Header.cs +++ b/osu.Game/Screens/OnlinePlay/Header.cs @@ -2,19 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using Humanizer; +using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay { @@ -22,52 +18,30 @@ namespace osu.Game.Screens.OnlinePlay { public const float HEIGHT = 80; + private readonly ScreenStack stack; + private readonly MultiHeaderTitle title; + public Header(string mainTitle, ScreenStack stack) { + this.stack = stack; + RelativeSizeAxes = Axes.X; Height = HEIGHT; + Padding = new MarginPadding { Left = WaveOverlayContainer.WIDTH_PADDING }; - HeaderBreadcrumbControl breadcrumbs; - MultiHeaderTitle title; - - Children = new Drawable[] + Child = title = new MultiHeaderTitle(mainTitle) { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"#1f1921"), - }, - new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = WaveOverlayContainer.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, - Children = new Drawable[] - { - title = new MultiHeaderTitle(mainTitle) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.BottomLeft, - }, - breadcrumbs = new HeaderBreadcrumbControl(stack) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft - } - }, - }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, }; - breadcrumbs.Current.ValueChanged += screen => - { - if (screen.NewValue is IOnlinePlaySubScreen onlineSubScreen) - title.Screen = onlineSubScreen; - }; - - breadcrumbs.Current.TriggerChange(); + // unnecessary to unbind these as this header has the same lifetime as the screen stack we are attaching to. + stack.ScreenPushed += (_, __) => updateSubScreenTitle(); + stack.ScreenExited += (_, __) => updateSubScreenTitle(); } + private void updateSubScreenTitle() => title.Screen = stack.CurrentScreen as IOnlinePlaySubScreen; + private class MultiHeaderTitle : CompositeDrawable { private const float spacing = 6; @@ -75,9 +49,10 @@ namespace osu.Game.Screens.OnlinePlay private readonly OsuSpriteText dot; private readonly OsuSpriteText pageTitle; + [CanBeNull] public IOnlinePlaySubScreen Screen { - set => pageTitle.Text = value.ShortTitle.Titleize(); + set => pageTitle.Text = value?.ShortTitle.Titleize() ?? string.Empty; } public MultiHeaderTitle(string mainTitle) @@ -97,21 +72,21 @@ namespace osu.Game.Screens.OnlinePlay { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 24), + Font = OsuFont.TorusAlternate.With(size: 24), Text = mainTitle }, dot = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 24), + Font = OsuFont.TorusAlternate.With(size: 24), Text = "·" }, pageTitle = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 24), + Font = OsuFont.TorusAlternate.With(size: 24), Text = "Lounge" } } @@ -125,35 +100,5 @@ namespace osu.Game.Screens.OnlinePlay pageTitle.Colour = dot.Colour = colours.Yellow; } } - - private class HeaderBreadcrumbControl : ScreenBreadcrumbControl - { - public HeaderBreadcrumbControl(ScreenStack stack) - : base(stack) - { - RelativeSizeAxes = Axes.X; - StripColour = Color4.Transparent; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - AccentColour = Color4Extensions.FromHex("#e35c99"); - } - - protected override TabItem CreateTabItem(IScreen value) => new HeaderBreadcrumbTabItem(value) - { - AccentColour = AccentColour - }; - - private class HeaderBreadcrumbTabItem : BreadcrumbTabItem - { - public HeaderBreadcrumbTabItem(IScreen value) - : base(value) - { - Bar.Colour = Color4.Transparent; - } - } - } } } diff --git a/osu.Game/Screens/OnlinePlay/IRoomManager.cs b/osu.Game/Screens/OnlinePlay/IRoomManager.cs index 8ff02536f3..6e1ffbda74 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))] @@ -16,31 +18,45 @@ namespace osu.Game.Screens.OnlinePlay /// event Action RoomsUpdated; - /// - /// Whether an initial listing of rooms has been received. - /// - IBindable InitialRoomsReceived { get; } - /// /// All the active s. /// IBindableList Rooms { get; } + /// + /// Adds a to this . + /// If already existing, the local room will be updated with the given one. + /// + /// The incoming . + void AddOrUpdateRoom(Room room); + + /// + /// Removes a from this . + /// + /// The to remove. + void RemoveRoom(Room room); + + /// + /// Removes all s from this . + /// + void ClearRooms(); + /// /// Creates a new . /// /// 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..acd87ed864 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -1,208 +1,268 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics.Sprites; 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.Online.Chat; using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Components; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class DrawableRoom : OsuClickableContainer, IStateful, IFilterable, IHasContextMenu + public class DrawableRoom : CompositeDrawable { - public const float SELECTION_BORDER_WIDTH = 4; - private const float corner_radius = 5; - private const float transition_duration = 60; - private const float content_padding = 10; - private const float height = 110; - private const float side_strip_width = 5; - private const float cover_width = 145; + protected const float CORNER_RADIUS = 10; + private const float height = 100; - public event Action StateChanged; - - private readonly Box selectionBox; - - [Resolved(canBeNull: true)] - private OnlinePlayScreen parentScreen { get; set; } + public readonly Room Room; [Resolved] private BeatmapManager beatmaps { get; set; } - public readonly Room Room; + protected Container ButtonsContainer { get; private set; } - private SelectionState state; + private readonly Bindable roomType = new Bindable(); + private readonly Bindable roomCategory = new Bindable(); + private readonly Bindable hasPassword = new Bindable(); - public SelectionState State - { - get => state; - set - { - if (value == state) return; + private DrawableRoomParticipantsList drawableRoomParticipantsList; + private RoomSpecialCategoryPill specialCategoryPill; + private PasswordProtectedIcon passwordIcon; + private EndDateInfo endDateInfo; - state = value; - - if (state == SelectionState.Selected) - selectionBox.FadeIn(transition_duration); - else - selectionBox.FadeOut(transition_duration); - - StateChanged?.Invoke(State); - } - } - - public IEnumerable FilterTerms => new[] { Room.Name.Value }; - - private bool matchingFilter; - - public bool MatchingFilter - { - get => matchingFilter; - set - { - matchingFilter = value; - - if (!IsLoaded) - return; - - if (matchingFilter) - this.FadeIn(200); - else - Hide(); - } - } - - public bool FilteringActive { get; set; } + private DelayedLoadWrapper wrapper; public DrawableRoom(Room room) { Room = room; RelativeSizeAxes = Axes.X; - Height = height + SELECTION_BORDER_WIDTH * 2; - CornerRadius = corner_radius + SELECTION_BORDER_WIDTH / 2; - Masking = true; + Height = height; - // create selectionBox here so State can be set before being loaded - selectionBox = new Box + Masking = true; + CornerRadius = CORNER_RADIUS; + EdgeEffect = new EdgeEffectParameters { - RelativeSizeAxes = Axes.Both, - Alpha = 0f, + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(40), + Radius = 5, }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colours) { - float stripWidth = side_strip_width * (Room.Category.Value == RoomCategory.Spotlight ? 2 : 1); - - Children = new Drawable[] + ButtonsContainer = new Container { - new StatusColouredContainer(transition_duration) + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X + }; + + InternalChildren = new[] + { + // This resolves internal 1px gaps due to applying the (parenting) corner radius and masking across multiple filling background sprites. + new Box { RelativeSizeAxes = Axes.Both, - Child = selectionBox + Colour = colours.Background5, }, - new Container + CreateBackground().With(d => { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(SELECTION_BORDER_WIDTH), - Child = new Container + d.RelativeSizeAxes = Axes.Both; + }), + wrapper = new DelayedLoadWrapper(() => + new Container { + Name = @"Room content", RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = corner_radius, - EdgeEffect = new EdgeEffectParameters + // This negative padding resolves 1px gaps between this background and the background above. + Padding = new MarginPadding { Left = 20, Vertical = -0.5f }, + Child = new Container { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(40), - Radius = 5, - }, - Children = new Drawable[] - { - new Box + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"212121"), - }, - new StatusColouredContainer(transition_duration) - { - RelativeSizeAxes = Axes.Y, - Width = stripWidth, - Child = new Box { RelativeSizeAxes = Axes.Both } - }, - new Container - { - RelativeSizeAxes = Axes.Y, - Width = cover_width, - Masking = true, - Margin = new MarginPadding { Left = stripWidth }, - Child = new OnlinePlayBackgroundSprite(BeatmapSetCoverType.List) { RelativeSizeAxes = Axes.Both } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding + new GridContainer { - Vertical = content_padding, - Left = stripWidth + cover_width + content_padding, - Right = content_padding, + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.2f) + }, + Content = new[] + { + new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Background5, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(colours.Background5, colours.Background5.Opacity(0.3f)) + }, + } + } }, - Children = new Drawable[] + new Container { - new FillFlowContainer + Name = @"Left details", + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5f), - Children = new Drawable[] + Left = 20, + Vertical = 5 + }, + Children = new Drawable[] + { + new FillFlowContainer { - new RoomName { Font = OsuFont.GetFont(size: 18) }, - new ParticipantInfo(), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new RoomStatusPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + specialCategoryPill = new RoomSpecialCategoryPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + endDateInfo = new EndDateInfo + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Top = 3 }, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new RoomNameText(), + new RoomStatusText() + } + } + }, }, - }, - new FillFlowContainer - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - Children = new Drawable[] + new FillFlowContainer { - new RoomStatusInfo(), - new BeatmapTitle { TextSize = 14 }, - }, - }, - new ModeTypeInfo - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new PlaylistCountPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new StarRatingRangeDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.8f) + } + } + } + } }, + new FillFlowContainer + { + Name = "Right content", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Padding = new MarginPadding + { + Right = 10, + Vertical = 20, + }, + Children = new Drawable[] + { + ButtonsContainer, + drawableRoomParticipantsList = new DrawableRoomParticipantsList + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + NumberOfCircles = NumberOfAvatars + } + } + }, + passwordIcon = new PasswordProtectedIcon { Alpha = 0 } }, }, - }, - }, + }, 0) + { + RelativeSizeAxes = Axes.Both, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + wrapper.DelayedLoadComplete += _ => + { + wrapper.FadeInFromZero(200); + + roomCategory.BindTo(Room.Category); + roomCategory.BindValueChanged(c => + { + if (c.NewValue == RoomCategory.Spotlight) + specialCategoryPill.Show(); + else + specialCategoryPill.Hide(); + }, true); + + roomType.BindTo(Room.Type); + roomType.BindValueChanged(t => + { + endDateInfo.Alpha = t.NewValue == MatchType.Playlists ? 1 : 0; + }, true); + + hasPassword.BindTo(Room.HasPassword); + hasPassword.BindValueChanged(v => passwordIcon.Alpha = v.NewValue ? 1 : 0, true); }; } @@ -214,23 +274,32 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components }; } - protected override void LoadComplete() - { - base.LoadComplete(); + private int numberOfAvatars = 7; - if (matchingFilter) - this.FadeInFromZero(transition_duration); - else - Alpha = 0; + public int NumberOfAvatars + { + get => numberOfAvatars; + set + { + numberOfAvatars = value; + + if (drawableRoomParticipantsList != null) + drawableRoomParticipantsList.NumberOfCircles = value; + } } - protected override bool ShouldBeConsideredForInput(Drawable child) => state == SelectionState.Selected; + protected virtual Drawable CreateBackground() => new OnlinePlayBackgroundSprite(); - private class RoomName : OsuSpriteText + private class RoomNameText : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))] private Bindable name { get; set; } + public RoomNameText() + { + Font = OsuFont.GetFont(size: 28); + } + [BackgroundDependencyLoader] private void load() { @@ -238,12 +307,121 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - public MenuItem[] ContextMenuItems => new MenuItem[] + private class RoomStatusText : OnlinePlayComposite { - new OsuMenuItem("Create copy", MenuItemType.Standard, () => + [Resolved] + private OsuColour colours { get; set; } + + private SpriteText statusText; + private LinkFlowContainer beatmapText; + + public RoomStatusText() { - parentScreen?.OpenNewRoom(Room.CreateCopy()); - }) - }; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Width = 0.5f; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + statusText = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 16), + Colour = colours.Lime1 + }, + beatmapText = new LinkFlowContainer(s => + { + s.Font = OsuFont.Default.With(size: 16); + s.Colour = colours.Lime1; + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + SelectedItem.BindValueChanged(onSelectedItemChanged, true); + } + + private void onSelectedItemChanged(ValueChangedEvent item) + { + beatmapText.Clear(); + + if (Type.Value == MatchType.Playlists) + { + statusText.Text = "Ready to play"; + return; + } + + if (item.NewValue?.Beatmap.Value != null) + { + statusText.Text = "Currently playing "; + beatmapText.AddLink(item.NewValue.Beatmap.Value.GetDisplayTitleRomanisable(), + LinkAction.OpenBeatmap, + item.NewValue.Beatmap.Value.OnlineBeatmapID.ToString(), + creationParameters: s => + { + s.Truncate = true; + s.RelativeSizeAxes = Axes.X; + }); + } + } + } + + public class PasswordProtectedIcon : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + 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), + } + }; + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs new file mode 100644 index 0000000000..31eb5db9bc --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs @@ -0,0 +1,352 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public class DrawableRoomParticipantsList : OnlinePlayComposite + { + private const float avatar_size = 36; + + private FillFlowContainer avatarFlow; + + private CircularAvatar hostAvatar; + private LinkFlowContainer hostText; + private HiddenUserCount hiddenUsers; + private OsuSpriteText totalCount; + + public DrawableRoomParticipantsList() + { + AutoSizeAxes = Axes.X; + Height = 60; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Shear = new Vector2(0.2f, 0), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Background4, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Spacing = new Vector2(8), + Padding = new MarginPadding + { + Left = 8, + Right = 16 + }, + Children = new Drawable[] + { + hostAvatar = new CircularAvatar + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + hostText = new LinkFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Shear = new Vector2(0.2f, 0), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Background3, + } + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Padding = new MarginPadding + { + Left = 8, + Right = 16 + }, + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(16), + Icon = FontAwesome.Solid.User, + }, + totalCount = new OsuSpriteText + { + Font = OsuFont.Default.With(weight: FontWeight.Bold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + avatarFlow = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Margin = new MarginPadding { Left = 4 }, + }, + hiddenUsers = new HiddenUserCount + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + } + } + } + }, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RecentParticipants.BindCollectionChanged(onParticipantsChanged, true); + ParticipantCount.BindValueChanged(_ => + { + updateHiddenUsers(); + totalCount.Text = ParticipantCount.Value.ToString(); + }, true); + + Host.BindValueChanged(onHostChanged, true); + } + + private int numberOfCircles = 4; + + /// + /// The maximum number of circles visible (including the "hidden count" circle in the overflow case). + /// + public int NumberOfCircles + { + get => numberOfCircles; + set + { + numberOfCircles = value; + + if (LoadState < LoadState.Loaded) + return; + + // Reinitialising the list looks janky, but this is unlikely to be used in a setting where it's visible. + clearUsers(); + foreach (var u in RecentParticipants) + addUser(u); + + updateHiddenUsers(); + } + } + + private void onParticipantsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var added in e.NewItems.OfType()) + addUser(added); + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var removed in e.OldItems.OfType()) + removeUser(removed); + break; + + case NotifyCollectionChangedAction.Reset: + clearUsers(); + break; + + case NotifyCollectionChangedAction.Replace: + case NotifyCollectionChangedAction.Move: + // Easiest is to just reinitialise the whole list. These are unlikely to ever be use cases. + clearUsers(); + foreach (var u in RecentParticipants) + addUser(u); + break; + } + + updateHiddenUsers(); + } + + private int displayedCircles => avatarFlow.Count + (hiddenUsers.Count > 0 ? 1 : 0); + + private void addUser(User user) + { + if (displayedCircles < NumberOfCircles) + avatarFlow.Add(new CircularAvatar { User = user }); + } + + private void removeUser(User user) + { + avatarFlow.RemoveAll(a => a.User == user); + } + + private void clearUsers() + { + avatarFlow.Clear(); + updateHiddenUsers(); + } + + private void updateHiddenUsers() + { + int hiddenCount = 0; + if (RecentParticipants.Count > NumberOfCircles) + hiddenCount = ParticipantCount.Value - NumberOfCircles + 1; + + hiddenUsers.Count = hiddenCount; + + if (displayedCircles > NumberOfCircles) + avatarFlow.Remove(avatarFlow.Last()); + else if (displayedCircles < NumberOfCircles) + { + var nextUser = RecentParticipants.FirstOrDefault(u => avatarFlow.All(a => a.User != u)); + if (nextUser != null) addUser(nextUser); + } + } + + private void onHostChanged(ValueChangedEvent host) + { + hostAvatar.User = host.NewValue; + hostText.Clear(); + + if (host.NewValue != null) + { + hostText.AddText("hosted by "); + hostText.AddUserLink(host.NewValue); + } + } + + private class CircularAvatar : CompositeDrawable + { + public User User + { + get => avatar.User; + set => avatar.User = value; + } + + private readonly UpdateableAvatar avatar = new UpdateableAvatar(showUsernameTooltip: true) { RelativeSizeAxes = Axes.Both }; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + Size = new Vector2(avatar_size); + + InternalChild = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colours.Background5, + RelativeSizeAxes = Axes.Both, + }, + avatar + } + }; + } + } + + public class HiddenUserCount : CompositeDrawable + { + public int Count + { + get => count; + set + { + count = value; + countText.Text = $"+{count}"; + + if (count > 0) + Show(); + else + Hide(); + } + } + + private int count; + + private readonly SpriteText countText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(weight: FontWeight.Bold), + }; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + Size = new Vector2(avatar_size); + Alpha = 0; + + InternalChild = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Background5, + }, + countText + } + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs new file mode 100644 index 0000000000..3207d373db --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public class EndDateInfo : OnlinePlayComposite + { + public EndDateInfo() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new EndDatePart + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12), + EndDate = { BindTarget = EndDate } + }; + } + + private class EndDatePart : DrawableDate + { + public readonly IBindable EndDate = new Bindable(); + + public EndDatePart() + : base(DateTimeOffset.UtcNow) + { + EndDate.BindValueChanged(date => + { + // If null, set a very large future date to prevent unnecessary schedules. + Date = date.NewValue ?? DateTimeOffset.Now.AddYears(1); + }, true); + } + + protected override string Format() + { + if (EndDate.Value == null) + return string.Empty; + + var diffToNow = Date.Subtract(DateTimeOffset.Now); + + if (diffToNow.TotalSeconds < -5) + return $"Closed {base.Format()}"; + + if (diffToNow.TotalSeconds < 0) + return "Closed"; + + if (diffToNow.TotalSeconds < 5) + return "Closing soon"; + + return $"Closing {base.Format()}"; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterControl.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterControl.cs deleted file mode 100644 index 7fc1c670ca..0000000000 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterControl.cs +++ /dev/null @@ -1,135 +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.Framework.Graphics.Shapes; -using osu.Framework.Threading; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; -using osuTK.Graphics; - -namespace osu.Game.Screens.OnlinePlay.Lounge.Components -{ - public abstract class FilterControl : CompositeDrawable - { - protected const float VERTICAL_PADDING = 10; - protected const float HORIZONTAL_PADDING = 80; - - [Resolved(CanBeNull = true)] - private Bindable filter { get; set; } - - [Resolved] - private IBindable ruleset { get; set; } - - private readonly Box tabStrip; - private readonly SearchTextBox search; - private readonly PageTabControl tabs; - - protected FilterControl() - { - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.25f, - }, - tabStrip = new Box - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = 1, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Top = VERTICAL_PADDING, - Horizontal = HORIZONTAL_PADDING - }, - Children = new Drawable[] - { - search = new FilterSearchTextBox - { - RelativeSizeAxes = Axes.X, - }, - tabs = new PageTabControl - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - }, - } - } - }; - - tabs.Current.Value = RoomStatusFilter.Open; - tabs.Current.TriggerChange(); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - filter ??= new Bindable(); - tabStrip.Colour = colours.Yellow; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - search.Current.BindValueChanged(_ => updateFilterDebounced()); - ruleset.BindValueChanged(_ => UpdateFilter()); - tabs.Current.BindValueChanged(_ => UpdateFilter(), true); - } - - private ScheduledDelegate scheduledFilterUpdate; - - private void updateFilterDebounced() - { - scheduledFilterUpdate?.Cancel(); - scheduledFilterUpdate = Scheduler.AddDelayed(UpdateFilter, 200); - } - - protected void UpdateFilter() => Scheduler.AddOnce(updateFilter); - - private void updateFilter() - { - scheduledFilterUpdate?.Cancel(); - - var criteria = CreateCriteria(); - criteria.SearchString = search.Current.Value; - criteria.Status = tabs.Current.Value; - criteria.Ruleset = ruleset.Value; - - filter.Value = criteria; - } - - protected virtual FilterCriteria CreateCriteria() => new FilterCriteria(); - - public bool HoldFocus - { - get => search.HoldFocus; - set => search.HoldFocus = value; - } - - public void TakeFocus() => search.TakeFocus(); - - private class FilterSearchTextBox : SearchTextBox - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundUnfocused = OsuColour.Gray(0.06f); - BackgroundFocused = OsuColour.Gray(0.12f); - } - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/ParticipantInfo.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/ParticipantInfo.cs deleted file mode 100644 index bc4506b78e..0000000000 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/ParticipantInfo.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Humanizer; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Users.Drawables; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay.Lounge.Components -{ - public class ParticipantInfo : OnlinePlayComposite - { - public ParticipantInfo() - { - RelativeSizeAxes = Axes.X; - Height = 15f; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - OsuSpriteText summary; - Container flagContainer; - LinkFlowContainer hostText; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5f, 0f), - Children = new Drawable[] - { - flagContainer = new Container - { - Width = 22f, - RelativeSizeAxes = Axes.Y, - }, - hostText = new LinkFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both - } - }, - }, - new FillFlowContainer - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Colour = colours.Gray9, - Children = new[] - { - summary = new OsuSpriteText - { - Text = "0 participants", - } - }, - }, - }; - - Host.BindValueChanged(host => - { - hostText.Clear(); - flagContainer.Clear(); - - if (host.NewValue != null) - { - hostText.AddText("hosted by "); - hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(Typeface.Torus, weight: FontWeight.Bold, italics: true)); - - flagContainer.Child = new UpdateableFlag(host.NewValue.Country) { RelativeSizeAxes = Axes.Both }; - } - }, true); - - ParticipantCount.BindValueChanged(count => summary.Text = "participant".ToQuantity(count.NewValue), true); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/PillContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/PillContainer.cs new file mode 100644 index 0000000000..109851a16b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/PillContainer.cs @@ -0,0 +1,81 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + /// + /// Displays contents in a "pill". + /// + public class PillContainer : Container + { + private const float padding = 8; + + public readonly Drawable Background; + + protected override Container Content => content; + private readonly Container content; + + public PillContainer() + { + AutoSizeAxes = Axes.X; + Height = 16; + + InternalChild = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Masking = true, + Children = new[] + { + Background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.5f + }, + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = padding }, + Child = new GridContainer + { + AutoSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize, minSize: 80 - 2 * padding) + }, + Content = new[] + { + new[] + { + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding { Bottom = 2 }, + Child = content = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + } + } + } + } + } + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs new file mode 100644 index 0000000000..2fe3c7b668 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.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.Specialized; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + /// + /// A pill that displays the playlist item count. + /// + public class PlaylistCountPill : OnlinePlayComposite + { + private OsuTextFlowContainer count; + + public PlaylistCountPill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new PillContainer + { + Child = count = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Playlist.BindCollectionChanged(updateCount, true); + } + + private void updateCount(object sender, NotifyCollectionChangedEventArgs e) + { + count.Clear(); + count.AddText(Playlist.Count.ToString(), s => s.Font = s.Font.With(weight: FontWeight.Bold)); + count.AddText(" "); + count.AddText("Beatmap".ToQuantity(Playlist.Count, ShowQuantityAs.None)); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistsFilterControl.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistsFilterControl.cs deleted file mode 100644 index a463742097..0000000000 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistsFilterControl.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.Graphics; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.OnlinePlay.Lounge.Components -{ - public class PlaylistsFilterControl : FilterControl - { - private readonly Dropdown dropdown; - - public PlaylistsFilterControl() - { - AddInternal(dropdown = new SlimEnumDropdown - { - Anchor = Anchor.BottomRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.None, - Width = 160, - X = -HORIZONTAL_PADDING, - Y = -30 - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - dropdown.Current.BindValueChanged(_ => UpdateFilter()); - } - - protected override FilterCriteria CreateCriteria() - { - var criteria = base.CreateCriteria(); - - switch (dropdown.Current.Value) - { - case PlaylistsCategory.Normal: - criteria.Category = "normal"; - break; - - case PlaylistsCategory.Spotlight: - criteria.Category = "spotlight"; - break; - } - - return criteria; - } - - private enum PlaylistsCategory - { - Any, - Normal, - Spotlight - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs new file mode 100644 index 0000000000..42fe0bfecd --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public class RankRangePill : MultiplayerRoomComposite + { + private OsuTextFlowContainer rankFlow; + + public RankRangePill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new PillContainer + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(4), + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(8), + Icon = FontAwesome.Solid.User + }, + rankFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + } + } + } + }; + } + + protected override void OnRoomUpdated() + { + base.OnRoomUpdated(); + + rankFlow.Clear(); + + if (Room == null || Room.Users.All(u => u.User == null)) + { + rankFlow.AddText("-"); + return; + } + + int minRank = Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Min(); + int maxRank = Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Max(); + + rankFlow.AddText("#"); + rankFlow.AddText(minRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold)); + + rankFlow.AddText(" - "); + + rankFlow.AddText("#"); + rankFlow.AddText(maxRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold)); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomInfo.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomInfo.cs deleted file mode 100644 index a0a7f2dc28..0000000000 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomInfo.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Screens.OnlinePlay.Components; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay.Lounge.Components -{ - public class RoomInfo : OnlinePlayComposite - { - private readonly List statusElements = new List(); - private readonly OsuTextFlowContainer roomName; - - public RoomInfo() - { - AutoSizeAxes = Axes.Y; - - RoomLocalUserInfo localUserInfo; - RoomStatusInfo statusInfo; - ModeTypeInfo typeInfo; - ParticipantInfo participantInfo; - - InternalChild = new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - Spacing = new Vector2(0, 10), - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - roomName = new OsuTextFlowContainer(t => t.Font = OsuFont.GetFont(size: 30)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - participantInfo = new ParticipantInfo(), - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - statusInfo = new RoomStatusInfo(), - typeInfo = new ModeTypeInfo - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight - } - } - }, - localUserInfo = new RoomLocalUserInfo(), - } - }; - - statusElements.AddRange(new Drawable[] - { - statusInfo, typeInfo, participantInfo, localUserInfo - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - if (RoomID.Value == null) - statusElements.ForEach(e => e.FadeOut()); - RoomID.BindValueChanged(id => - { - if (id.NewValue == null) - statusElements.ForEach(e => e.FadeOut(100)); - else - statusElements.ForEach(e => e.FadeIn(100)); - }, true); - RoomName.BindValueChanged(name => - { - roomName.Text = name.NewValue ?? "No room selected"; - }, true); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomInspector.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomInspector.cs deleted file mode 100644 index c28354c753..0000000000 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomInspector.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Screens.OnlinePlay.Components; -using osuTK.Graphics; - -namespace osu.Game.Screens.OnlinePlay.Lounge.Components -{ - public class RoomInspector : OnlinePlayComposite - { - private const float transition_duration = 100; - - private readonly MarginPadding contentPadding = new MarginPadding { Horizontal = 20, Vertical = 10 }; - - [Resolved] - private BeatmapManager beatmaps { get; set; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - OverlinedHeader participantsHeader; - - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.25f - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 30 }, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new RoomInfo - { - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Vertical = 60 }, - }, - participantsHeader = new OverlinedHeader("Recent Participants"), - new ParticipantsDisplay(Direction.Vertical) - { - RelativeSizeAxes = Axes.X, - Height = ParticipantsList.TILE_SIZE * 3, - Details = { BindTarget = participantsHeader.Details } - } - } - } - }, - new Drawable[] { new OverlinedPlaylistHeader(), }, - new Drawable[] - { - new DrawableRoomPlaylist(false, false) - { - RelativeSizeAxes = Axes.Both, - Items = { BindTarget = Playlist } - }, - }, - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - } - } - }; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs new file mode 100644 index 0000000000..6cdbeb2af4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public class RoomSpecialCategoryPill : OnlinePlayComposite + { + private SpriteText text; + + public RoomSpecialCategoryPill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChild = new PillContainer + { + Background = + { + Colour = colours.Pink, + Alpha = 1 + }, + Child = text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12), + Colour = Color4.Black + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Category.BindValueChanged(c => text.Text = c.NewValue.ToString(), true); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs new file mode 100644 index 0000000000..1d43f2dc65 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + /// + /// A pill that displays the room's current status. + /// + public class RoomStatusPill : OnlinePlayComposite + { + [Resolved] + private OsuColour colours { get; set; } + + private PillContainer pill; + private SpriteText statusText; + + public RoomStatusPill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = pill = new PillContainer + { + Child = statusText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12), + Colour = Color4.Black + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + EndDate.BindValueChanged(_ => updateDisplay()); + Status.BindValueChanged(_ => updateDisplay(), true); + + FinishTransforms(true); + } + + private void updateDisplay() + { + RoomStatus status = getDisplayStatus(); + + pill.Background.Alpha = 1; + pill.Background.FadeColour(status.GetAppropriateColour(colours), 100); + statusText.Text = status.Message; + } + + private RoomStatus getDisplayStatus() + { + if (EndDate.Value < DateTimeOffset.Now) + return new RoomStatusEnded(); + + return Status.Value; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index 5f135a3e90..85efdcef1a 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -15,7 +15,6 @@ using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Extensions; using osu.Game.Graphics.Cursor; -using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Online.Rooms; using osuTK; @@ -24,18 +23,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public class RoomsContainer : CompositeDrawable, IKeyBindingHandler { - public Action JoinRequested; + public readonly Bindable SelectedRoom = new Bindable(); + public readonly Bindable Filter = new Bindable(); + + public IReadOnlyList Rooms => roomFlow.FlowingChildren.Cast().ToArray(); private readonly IBindableList rooms = new BindableList(); - - private readonly FillFlowContainer roomFlow; - public IReadOnlyList Rooms => roomFlow; - - [Resolved(CanBeNull = true)] - private Bindable filter { get; set; } - - [Resolved] - private Bindable selectedRoom { get; set; } + private readonly FillFlowContainer roomFlow; [Resolved] private IRoomManager roomManager { get; set; } @@ -51,16 +45,19 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + // account for the fact we are in a scroll container and want a bit of spacing from the scroll bar. + Padding = new MarginPadding { Right = 5 }; + InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Child = roomFlow = new FillFlowContainer + Child = roomFlow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Spacing = new Vector2(2), + Spacing = new Vector2(10), } }; } @@ -72,18 +69,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components rooms.BindTo(roomManager.Rooms); - filter?.BindValueChanged(criteria => Filter(criteria.NewValue)); - - selectedRoom.BindValueChanged(selection => - { - updateSelection(); - }, true); + Filter?.BindValueChanged(criteria => applyFilterCriteria(criteria.NewValue), true); } - private void updateSelection() => - roomFlow.Children.ForEach(r => r.State = r.Room == selectedRoom.Value ? SelectionState.Selected : SelectionState.NotSelected); - - public void Filter(FilterCriteria criteria) + private void applyFilterCriteria(FilterCriteria criteria) { roomFlow.Children.ForEach(r => { @@ -120,90 +109,61 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void addRooms(IEnumerable rooms) { foreach (var room in rooms) - { - roomFlow.Add(new DrawableRoom(room) - { - Action = () => - { - if (room == selectedRoom.Value) - { - joinSelected(); - return; - } + roomFlow.Add(new DrawableLoungeRoom(room) { SelectedRoom = { BindTarget = SelectedRoom } }); - selectRoom(room); - } - }); - } - - Filter(filter?.Value); - - updateSelection(); + applyFilterCriteria(Filter?.Value); } private void removeRooms(IEnumerable rooms) { foreach (var r in rooms) { - var toRemove = roomFlow.Single(d => d.Room == r); - toRemove.Action = null; + roomFlow.RemoveAll(d => d.Room == r); - roomFlow.Remove(toRemove); - - selectRoom(null); + // selection may have a lease due to being in a sub screen. + if (!SelectedRoom.Disabled) + SelectedRoom.Value = null; } } private void updateSorting() { foreach (var room in roomFlow) - 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); + roomFlow.SetLayoutPosition(room, -(room.Room.RoomID.Value ?? 0)); } protected override bool OnClick(ClickEvent e) { - selectRoom(null); + if (!SelectedRoom.Disabled) + SelectedRoom.Value = null; return base.OnClick(e); } #region Key selection logic (shared with BeatmapCarousel) - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { - case GlobalAction.Select: - joinSelected(); - return true; - case GlobalAction.SelectNext: - beginRepeatSelection(() => selectNext(1), action); + beginRepeatSelection(() => selectNext(1), e.Action); return true; case GlobalAction.SelectPrevious: - beginRepeatSelection(() => selectNext(-1), action); + beginRepeatSelection(() => selectNext(-1), e.Action); return true; } return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.SelectNext: case GlobalAction.SelectPrevious: - endRepeatSelection(action); + endRepeatSelection(e.Action); break; } } @@ -237,23 +197,26 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void selectNext(int direction) { + if (SelectedRoom.Disabled) + return; + var visibleRooms = Rooms.AsEnumerable().Where(r => r.IsPresent); Room room; - if (selectedRoom.Value == null) + if (SelectedRoom.Value == null) room = visibleRooms.FirstOrDefault()?.Room; else { if (direction < 0) visibleRooms = visibleRooms.Reverse(); - room = visibleRooms.SkipWhile(r => r.Room != selectedRoom.Value).Skip(1).FirstOrDefault()?.Room; + room = visibleRooms.SkipWhile(r => r.Room != SelectedRoom.Value).Skip(1).FirstOrDefault()?.Room; } // we already have a valid selection only change selection if we still have a room to switch to. if (room != null) - selectRoom(room); + SelectedRoom.Value = room; } #endregion diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs new file mode 100644 index 0000000000..fe7c7cc364 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.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.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Extensions; +using osu.Game.Graphics; +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 osu.Game.Screens.OnlinePlay.Lounge.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Lounge +{ + /// + /// A with lounge-specific interactions such as selection and hover sounds. + /// + public class DrawableLoungeRoom : DrawableRoom, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler + { + private const float transition_duration = 60; + private const float selection_border_width = 4; + + public readonly Bindable SelectedRoom = new Bindable(); + + [Resolved(canBeNull: true)] + private LoungeSubScreen lounge { get; set; } + + private Sample sampleSelect; + private Sample sampleJoin; + private Drawable selectionBox; + + public DrawableLoungeRoom(Room room) + : base(room) + { + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleSelect = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); + sampleJoin = audio.Samples.Get($@"UI/{HoverSampleSet.Submit.GetDescription()}-select"); + + AddRangeInternal(new Drawable[] + { + new StatusColouredContainer(transition_duration) + { + RelativeSizeAxes = Axes.Both, + Child = selectionBox = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Masking = true, + CornerRadius = CORNER_RADIUS, + BorderThickness = selection_border_width, + BorderColour = Color4.White, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + }, + new HoverSounds() + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Alpha = matchingFilter ? 1 : 0; + selectionBox.Alpha = SelectedRoom.Value == Room ? 1 : 0; + + SelectedRoom.BindValueChanged(updateSelectedRoom); + } + + private void updateSelectedRoom(ValueChangedEvent selected) + { + if (selected.NewValue == Room) + selectionBox.FadeIn(transition_duration); + else + selectionBox.FadeOut(transition_duration); + } + + public bool FilteringActive { get; set; } + + public IEnumerable FilterTerms => new[] { Room.Name.Value }; + + private bool matchingFilter; + + public bool MatchingFilter + { + get => matchingFilter; + set + { + matchingFilter = value; + + if (!IsLoaded) + return; + + if (matchingFilter) + this.FadeIn(200); + else + Hide(); + } + } + + public Popover GetPopover() => new PasswordEntryPopover(Room); + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Create copy", MenuItemType.Standard, () => + { + lounge?.Open(Room.DeepClone()); + }) + }; + + public bool OnPressed(KeyBindingPressEvent e) + { + if (SelectedRoom.Value != Room) + return false; + + switch (e.Action) + { + case GlobalAction.Select: + TriggerClick(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + protected override bool ShouldBeConsideredForInput(Drawable child) => SelectedRoom.Value == Room || child is HoverSounds; + + protected override bool OnClick(ClickEvent e) + { + if (Room != SelectedRoom.Value) + { + sampleSelect?.Play(); + SelectedRoom.Value = Room; + return true; + } + + if (Room.HasPassword.Value) + { + sampleJoin?.Play(); + this.ShowPopover(); + return true; + } + + sampleJoin?.Play(); + lounge?.Join(Room, null); + return true; + } + + public class PasswordEntryPopover : OsuPopover + { + private readonly Room room; + + [Resolved(canBeNull: true)] + private LoungeSubScreen lounge { get; set; } + + public PasswordEntryPopover(Room room) + { + this.room = room; + } + + private OsuPasswordTextBox passwordTextbox; + private TriangleButton joinButton; + private OsuSpriteText errorText; + private Sample sampleJoinFail; + + [BackgroundDependencyLoader] + private void load(OsuColour colours, AudioManager audio) + { + Child = new FillFlowContainer + { + Margin = new MarginPadding(10), + Spacing = new Vector2(5), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + passwordTextbox = new OsuPasswordTextBox + { + Width = 200, + PlaceholderText = "password", + }, + joinButton = new TriangleButton + { + Width = 80, + Text = "Join Room", + } + } + }, + errorText = new OsuSpriteText + { + Colour = colours.Red, + }, + } + }; + + sampleJoinFail = audio.Samples.Get(@"UI/password-fail"); + + joinButton.Action = () => lounge?.Join(room, passwordTextbox.Text, null, joinFailed); + } + + private void joinFailed(string error) + { + passwordTextbox.Text = string.Empty; + + GetContainingInputManager().ChangeFocus(passwordTextbox); + + errorText.Text = error; + errorText + .FadeIn() + .FlashColour(Color4.White, 200) + .Delay(1000) + .FadeOutFromOne(1000, Easing.In); + + Body.Shake(); + + sampleJoinFail?.Play(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextbox)); + passwordTextbox.OnCommit += (_, __) => lounge?.Join(room, passwordTextbox.Text, null, joinFailed); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs new file mode 100644 index 0000000000..6c00ca2e81 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.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. + +#nullable enable + +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Screens; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Components; +using PlaylistItem = osu.Game.Online.Rooms.PlaylistItem; + +namespace osu.Game.Screens.OnlinePlay.Lounge +{ + public class LoungeBackgroundScreen : OnlinePlayBackgroundScreen + { + public readonly Bindable SelectedRoom = new Bindable(); + private readonly BindableList playlist = new BindableList(); + + public LoungeBackgroundScreen() + { + SelectedRoom.BindValueChanged(onSelectedRoomChanged); + playlist.BindCollectionChanged((_, __) => PlaylistItem = playlist.FirstOrDefault()); + } + + private void onSelectedRoomChanged(ValueChangedEvent room) + { + if (room.OldValue != null) + playlist.UnbindFrom(room.OldValue.Playlist); + + if (room.NewValue != null) + playlist.BindTo(room.NewValue.Playlist); + else + playlist.Clear(); + } + + public override bool OnExiting(IScreen next) + { + // This screen never exits. + return true; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index f24577a8a5..08bdd0487a 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -2,21 +2,33 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; +using System.Collections.Generic; 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.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Framework.Threading; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Input; using osu.Game.Online.Rooms; using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Users; +using osuTK; namespace osu.Game.Screens.OnlinePlay.Lounge { @@ -25,17 +37,23 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { public override string Title => "Lounge"; + protected override BackgroundScreen CreateBackground() => new LoungeBackgroundScreen + { + SelectedRoom = { BindTarget = SelectedRoom } + }; + protected override UserActivity InitialActivity => new UserActivity.SearchingForLobby(); - private readonly IBindable initialRoomsReceived = new Bindable(); - private readonly IBindable operationInProgress = new Bindable(); + protected Container Buttons { get; } = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Both + }; - private FilterControl filter; - private Container content; - private LoadingLayer loadingLayer; + protected ListingPollingComponent ListingPollingComponent { get; private set; } - [Resolved] - private Bindable selectedRoom { get; set; } + protected readonly Bindable SelectedRoom = new Bindable(); [Resolved] private MusicController music { get; set; } @@ -43,56 +61,113 @@ namespace osu.Game.Screens.OnlinePlay.Lounge [Resolved(CanBeNull = true)] private OngoingOperationTracker ongoingOperationTracker { get; set; } + [Resolved] + private IBindable ruleset { get; set; } + [CanBeNull] private IDisposable joiningRoomOperation { get; set; } - [BackgroundDependencyLoader] - private void load() + [CanBeNull] + private LeasedBindable selectionLease; + + private readonly Bindable filter = new Bindable(new FilterCriteria()); + private readonly IBindable operationInProgress = new Bindable(); + private readonly IBindable isIdle = new BindableBool(); + private PopoverContainer popoverContainer; + private LoadingLayer loadingLayer; + private RoomsContainer roomsContainer; + private SearchTextBox searchTextBox; + private Dropdown statusDropdown; + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] IdleTracker idleTracker) { - RoomsContainer roomsContainer; + const float controls_area_height = 25f; + + if (idleTracker != null) + isIdle.BindTo(idleTracker.IsIdle); + OsuScrollContainer scrollContainer; InternalChildren = new Drawable[] { - content = new Container + ListingPollingComponent = CreatePollingComponent().With(c => c.Filter.BindTarget = filter), + popoverContainer = new PopoverContainer { + Name = @"Rooms area", RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Top = Header.HEIGHT + controls_area_height + 20, + }, + Child = scrollContainer = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarOverlapsContent = false, + Child = roomsContainer = new RoomsContainer + { + Filter = { BindTarget = filter }, + SelectedRoom = { BindTarget = SelectedRoom } + } + }, + }, + loadingLayer = new LoadingLayer(true), + new FillFlowContainer + { + Name = @"Header area flow", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.WIDTH_PADDING }, + Direction = FillDirection.Vertical, Children = new Drawable[] { new Container { - RelativeSizeAxes = Axes.Both, - Width = 0.55f, + RelativeSizeAxes = Axes.X, + Height = Header.HEIGHT, + Child = searchTextBox = new LoungeSearchTextBox + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.X, + Width = 0.6f, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = controls_area_height, Children = new Drawable[] { - scrollContainer = new OsuScrollContainer + Buttons.WithChild(CreateNewRoomButton().With(d => { - RelativeSizeAxes = Axes.Both, - ScrollbarOverlapsContent = false, - Padding = new MarginPadding(10), - Child = roomsContainer = new RoomsContainer { JoinRequested = joinRequested } - }, - loadingLayer = new LoadingLayer(true), + d.Anchor = Anchor.BottomLeft; + d.Origin = Anchor.BottomLeft; + d.Size = new Vector2(150, 37.5f); + d.Action = () => Open(); + })), + new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + ChildrenEnumerable = CreateFilterControls().Select(f => f.With(d => + { + d.Anchor = Anchor.TopRight; + d.Origin = Anchor.TopRight; + })) + } } - }, - new RoomInspector - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Both, - Width = 0.45f, - }, + } }, }, - filter = CreateFilterControl().With(d => - { - d.RelativeSizeAxes = Axes.X; - d.Height = 80; - }) }; // scroll selected room into view on selection. - selectedRoom.BindValueChanged(val => + SelectedRoom.BindValueChanged(val => { var drawable = roomsContainer.Rooms.FirstOrDefault(r => r.Room == val.NewValue); if (drawable != null) @@ -104,37 +179,65 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { base.LoadComplete(); - initialRoomsReceived.BindTo(RoomManager.InitialRoomsReceived); - initialRoomsReceived.BindValueChanged(_ => updateLoadingLayer()); + searchTextBox.Current.BindValueChanged(_ => updateFilterDebounced()); + ruleset.BindValueChanged(_ => UpdateFilter()); + + isIdle.BindValueChanged(_ => updatePollingRate(this.IsCurrentScreen()), true); if (ongoingOperationTracker != null) { operationInProgress.BindTo(ongoingOperationTracker.InProgress); - operationInProgress.BindValueChanged(_ => updateLoadingLayer(), true); + operationInProgress.BindValueChanged(_ => updateLoadingLayer()); } + + ListingPollingComponent.InitialRoomsReceived.BindValueChanged(_ => updateLoadingLayer(), true); + + updateFilter(); } - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); + #region Filtering - content.Padding = new MarginPadding + public void UpdateFilter() => Scheduler.AddOnce(updateFilter); + + private ScheduledDelegate scheduledFilterUpdate; + + private void updateFilterDebounced() + { + scheduledFilterUpdate?.Cancel(); + scheduledFilterUpdate = Scheduler.AddDelayed(UpdateFilter, 200); + } + + private void updateFilter() + { + scheduledFilterUpdate?.Cancel(); + filter.Value = CreateFilterCriteria(); + } + + protected virtual FilterCriteria CreateFilterCriteria() => new FilterCriteria + { + SearchString = searchTextBox.Current.Value, + Ruleset = ruleset.Value, + Status = statusDropdown.Current.Value + }; + + protected virtual IEnumerable CreateFilterControls() + { + statusDropdown = new SlimEnumDropdown { - Top = filter.DrawHeight, - Left = WaveOverlayContainer.WIDTH_PADDING - DrawableRoom.SELECTION_BORDER_WIDTH + HORIZONTAL_OVERFLOW_PADDING, - Right = WaveOverlayContainer.WIDTH_PADDING + HORIZONTAL_OVERFLOW_PADDING, + RelativeSizeAxes = Axes.None, + Width = 160, }; + + statusDropdown.Current.BindValueChanged(_ => UpdateFilter()); + + yield return statusDropdown; } - protected override void OnFocus(FocusEvent e) - { - filter.TakeFocus(); - } + #endregion public override void OnEntering(IScreen last) { base.OnEntering(last); - onReturning(); } @@ -142,74 +245,132 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { base.OnResuming(last); - if (selectedRoom.Value?.RoomID.Value == null) - selectedRoom.Value = new Room(); + Debug.Assert(selectionLease != null); + + selectionLease.Return(); + selectionLease = null; + + if (SelectedRoom.Value?.RoomID.Value == null) + SelectedRoom.Value = new Room(); music?.EnsurePlayingSomething(); 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) + protected override void OnFocus(FocusEvent e) + { + searchTextBox.TakeFocus(); + } + + private void onReturning() + { + updatePollingRate(true); + searchTextBox.HoldFocus = true; + } + + private void onLeaving() + { + updatePollingRate(false); + searchTextBox.HoldFocus = false; + + // ensure any password prompt is dismissed. + popoverContainer.HidePopover(); + } + + public void Join(Room room, string password, Action onSuccess = null, Action onFailure = null) => Schedule(() => { if (joiningRoomOperation != null) return; joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); - RoomManager?.JoinRoom(room, r => + RoomManager?.JoinRoom(room, password, r => { Open(room); joiningRoomOperation?.Dispose(); joiningRoomOperation = null; - }, _ => + onSuccess?.Invoke(room); + }, error => { joiningRoomOperation?.Dispose(); joiningRoomOperation = null; + onFailure?.Invoke(error); }); - } - - private void updateLoadingLayer() - { - if (operationInProgress.Value || !initialRoomsReceived.Value) - loadingLayer.Show(); - else - loadingLayer.Hide(); - } + }); /// /// Push a room as a new subscreen. /// - public virtual void Open(Room room) + /// An optional template to use when creating the room. + public void Open(Room room = null) => Schedule(() => { // Handles the case where a room is clicked 3 times in quick succession if (!this.IsCurrentScreen()) return; - selectedRoom.Value = room; + OpenNewRoom(room ?? CreateNewRoom()); + }); + + protected virtual void OpenNewRoom(Room room) + { + selectionLease = SelectedRoom.BeginLease(false); + Debug.Assert(selectionLease != null); + selectionLease.Value = room; this.Push(CreateRoomSubScreen(room)); } - protected abstract FilterControl CreateFilterControl(); + private void updateLoadingLayer() + { + if (operationInProgress.Value || !ListingPollingComponent.InitialRoomsReceived.Value) + loadingLayer.Show(); + else + loadingLayer.Hide(); + } + + private void updatePollingRate(bool isCurrentScreen) + { + if (!isCurrentScreen) + ListingPollingComponent.TimeBetweenPolls.Value = 0; + else + ListingPollingComponent.TimeBetweenPolls.Value = isIdle.Value ? 120000 : 15000; + + Logger.Log($"Polling adjusted (listing: {ListingPollingComponent.TimeBetweenPolls.Value})"); + } + + protected abstract OsuButton CreateNewRoomButton(); + + /// + /// Creates a new room. + /// + /// The created . + protected abstract Room CreateNewRoom(); protected abstract RoomSubScreen CreateRoomSubScreen(Room room); + + protected abstract ListingPollingComponent CreatePollingComponent(); + + private class LoungeSearchTextBox : SearchTextBox + { + [BackgroundDependencyLoader] + private void load() + { + BackgroundUnfocused = OsuColour.Gray(0.06f); + BackgroundFocused = OsuColour.Gray(0.12f); + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/CreateRoomButton.cs b/osu.Game/Screens/OnlinePlay/Match/Components/CreateRoomButton.cs new file mode 100644 index 0000000000..53131ab90e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Match/Components/CreateRoomButton.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.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; + +namespace osu.Game.Screens.OnlinePlay.Match.Components +{ + public abstract class CreateRoomButton : PurpleTriangleButton, IKeyBindingHandler + { + [BackgroundDependencyLoader] + private void load() + { + SpriteText.Font = SpriteText.Font.With(size: 14); + Triangles.TriangleScale = 1.5f; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (!Enabled.Value) + return false; + + switch (e.Action) + { + case PlatformAction.DocumentNew: + // might as well also handle new tab. it's a bit of an undefined flow on this screen. + case PlatformAction.TabNew: + TriggerClick(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/Footer.cs b/osu.Game/Screens/OnlinePlay/Match/Components/Footer.cs deleted file mode 100644 index e91c46beed..0000000000 --- a/osu.Game/Screens/OnlinePlay/Match/Components/Footer.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 System; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Screens.OnlinePlay.Playlists; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay.Match.Components -{ - public class Footer : CompositeDrawable - { - public const float HEIGHT = 50; - - public Action OnStart; - - private readonly Drawable background; - - public Footer() - { - RelativeSizeAxes = Axes.X; - Height = HEIGHT; - - InternalChildren = new[] - { - background = new Box { RelativeSizeAxes = Axes.Both }, - new PlaylistsReadyButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(600, 50), - Action = () => OnStart?.Invoke() - } - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - background.Colour = Color4Extensions.FromHex(@"28242d"); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/Header.cs b/osu.Game/Screens/OnlinePlay/Match/Components/Header.cs deleted file mode 100644 index a2d11c54c1..0000000000 --- a/osu.Game/Screens/OnlinePlay/Match/Components/Header.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -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.Users.Drawables; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay.Match.Components -{ - public class Header : OnlinePlayComposite - { - public const float HEIGHT = 50; - - private UpdateableAvatar avatar; - private LinkFlowContainer hostText; - - public Header() - { - RelativeSizeAxes = Axes.X; - Height = HEIGHT; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - avatar = new UpdateableAvatar - { - Size = new Vector2(50), - Masking = true, - CornerRadius = 10, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30), - Current = { BindTarget = RoomName } - }, - hostText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 20)) - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - } - } - } - } - }; - - Host.BindValueChanged(host => - { - avatar.User = host.NewValue; - - hostText.Clear(); - - if (host.NewValue != null) - { - hostText.AddText("hosted by "); - hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); - } - }, true); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs index a96d64cb5d..0396562959 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs @@ -10,39 +10,44 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components { public class MatchChatDisplay : StandAloneChatDisplay { - [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } - - [Resolved(typeof(Room), nameof(Room.ChannelId))] - private Bindable channelId { get; set; } + private readonly IBindable channelId = new Bindable(); [Resolved(CanBeNull = true)] private ChannelManager channelManager { get; set; } - public MatchChatDisplay() + private readonly Room room; + private readonly bool leaveChannelOnDispose; + + public MatchChatDisplay(Room room, bool leaveChannelOnDispose = true) : base(true) { + this.room = room; + this.leaveChannelOnDispose = leaveChannelOnDispose; } protected override void LoadComplete() { base.LoadComplete(); + // Required for the time being since this component is created prior to the room being joined. + channelId.BindTo(room.ChannelId); channelId.BindValueChanged(_ => updateChannel(), true); } private void updateChannel() { - if (roomId.Value == null || channelId.Value == 0) + if (room.RoomID.Value == null || channelId.Value == 0) return; - Channel.Value = channelManager?.JoinChannel(new Channel { Id = channelId.Value, Type = ChannelType.Multiplayer, Name = $"#lazermp_{roomId.Value}" }); + Channel.Value = channelManager?.JoinChannel(new Channel { Id = channelId.Value, Type = ChannelType.Multiplayer, Name = $"#lazermp_{room.RoomID.Value}" }); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - channelManager?.LeaveChannel(Channel.Value); + + if (leaveChannelOnDispose) + channelManager?.LeaveChannel(Channel.Value); } } } diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/GameTypePicker.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs similarity index 84% rename from osu.Game/Screens/OnlinePlay/Match/Components/GameTypePicker.cs rename to osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs index cca1f84bbb..216734e55e 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/GameTypePicker.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs @@ -8,32 +8,29 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.GameTypes; using osu.Game.Screens.OnlinePlay.Components; using osuTK; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public class GameTypePicker : DisableableTabControl + public class MatchTypePicker : DisableableTabControl { private const float height = 40; private const float selection_width = 3; - protected override TabItem CreateTabItem(GameType value) => new GameTypePickerItem(value); + protected override TabItem CreateTabItem(MatchType value) => new GameTypePickerItem(value); - protected override Dropdown CreateDropdown() => null; + protected override Dropdown CreateDropdown() => null; - public GameTypePicker() + public MatchTypePicker() { Height = height + selection_width * 2; TabContainer.Spacing = new Vector2(10 - selection_width * 2); - AddItem(new GameTypeTag()); - AddItem(new GameTypeVersus()); - AddItem(new GameTypeTagTeam()); - AddItem(new GameTypeTeamVersus()); - AddItem(new GameTypePlaylists()); + AddItem(MatchType.HeadToHead); + AddItem(MatchType.TeamVersus); } private class GameTypePickerItem : DisableableTabItem @@ -42,7 +39,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components private readonly CircularContainer hover, selection; - public GameTypePickerItem(GameType value) + public GameTypePickerItem(MatchType value) : base(value) { AutoSizeAxes = Axes.Both; @@ -79,6 +76,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components }, }, }, + new HoverClickSounds(), }; } diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs similarity index 66% rename from osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs rename to osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs index 5699da740c..a6cdde14f6 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs @@ -4,37 +4,91 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Online.Rooms; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public abstract class MatchSettingsOverlay : FocusedOverlayContainer + public abstract class RoomSettingsOverlay : FocusedOverlayContainer, IKeyBindingHandler { protected const float TRANSITION_DURATION = 350; - protected const float FIELD_PADDING = 45; + protected const float FIELD_PADDING = 25; protected OnlinePlayComposite Settings { get; set; } protected override bool BlockScrollInput => false; + protected abstract OsuButton SubmitButton { get; } + + protected abstract bool IsLoading { get; } + + private readonly Room room; + + protected RoomSettingsOverlay(Room room) + { + this.room = room; + + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 10; + } + [BackgroundDependencyLoader] private void load() { - Masking = true; + Add(Settings = CreateSettings(room)); } + protected abstract void SelectBeatmap(); + + protected abstract OnlinePlayComposite CreateSettings(Room room); + protected override void PopIn() { + base.PopIn(); Settings.MoveToY(0, TRANSITION_DURATION, Easing.OutQuint); + Settings.FadeIn(TRANSITION_DURATION / 2); } protected override void PopOut() { + base.PopOut(); Settings.MoveToY(-1, TRANSITION_DURATION, Easing.InSine); + Settings.Delay(TRANSITION_DURATION / 2).FadeOut(TRANSITION_DURATION / 2); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.Select: + if (IsLoading) + return true; + + if (SubmitButton.Enabled.Value) + { + SubmitButton.TriggerClick(); + return true; + } + else + { + SelectBeatmap(); + return true; + } + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { } protected class SettingsTextBox : OsuTextBox diff --git a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs new file mode 100644 index 0000000000..e83403850f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Match +{ + public class DrawableMatchRoom : DrawableRoom + { + public readonly IBindable SelectedItem = new Bindable(); + public Action OnEdit; + + [Resolved] + private IAPIProvider api { get; set; } + + private readonly IBindable host = new Bindable(); + private readonly bool allowEdit; + + [CanBeNull] + private Drawable editButton; + + private BackgroundSprite background; + + public DrawableMatchRoom(Room room, bool allowEdit = true) + : base(room) + { + this.allowEdit = allowEdit; + + host.BindTo(room.Host); + } + + [BackgroundDependencyLoader] + private void load() + { + if (allowEdit) + { + ButtonsContainer.Add(editButton = new PurpleTriangleButton + { + RelativeSizeAxes = Axes.Y, + Size = new Vector2(100, 1), + Text = "Edit", + Action = () => OnEdit?.Invoke() + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (editButton != null) + host.BindValueChanged(h => editButton.Alpha = h.NewValue?.Equals(api.LocalUser.Value) == true ? 1 : 0, true); + + SelectedItem.BindValueChanged(item => background.Beatmap.Value = item.NewValue?.Beatmap.Value, true); + } + + protected override Drawable CreateBackground() => background = new BackgroundSprite(); + + private class BackgroundSprite : UpdateableBeatmapBackgroundSprite + { + protected override double LoadDelay => 0; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomBackgroundScreen.cs new file mode 100644 index 0000000000..2e5f25370f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Match/RoomBackgroundScreen.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Components; + +namespace osu.Game.Screens.OnlinePlay.Match +{ + public class RoomBackgroundScreen : OnlinePlayBackgroundScreen + { + public readonly Bindable SelectedItem = new Bindable(); + + public RoomBackgroundScreen(PlaylistItem initialPlaylistItem) + { + PlaylistItem = initialPlaylistItem; + SelectedItem.BindValueChanged(item => PlaylistItem = item.NewValue); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index a53e253581..bcb793062b 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -8,8 +8,10 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -17,6 +19,7 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.OnlinePlay.Match.Components; namespace osu.Game.Screens.OnlinePlay.Match { @@ -26,9 +29,14 @@ namespace osu.Game.Screens.OnlinePlay.Match [Cached(typeof(IBindable))] protected readonly Bindable SelectedItem = new Bindable(); - public override bool DisallowExternalBeatmapRulesetChanges => true; + public override bool? AllowTrackAdjustments => true; - private readonly ModSelectOverlay userModsSelectOverlay; + protected override BackgroundScreen CreateBackground() => new RoomBackgroundScreen(Room.Playlist.FirstOrDefault()) + { + SelectedItem = { BindTarget = SelectedItem } + }; + + public override bool DisallowExternalBeatmapRulesetChanges => true; /// /// A container that provides controls for selection of user mods. @@ -43,6 +51,8 @@ namespace osu.Game.Screens.OnlinePlay.Match /// protected readonly Bindable> UserMods = new Bindable>(Array.Empty()); + protected readonly IBindable RoomId = new Bindable(); + [Resolved] private MusicController music { get; set; } @@ -55,48 +65,185 @@ namespace osu.Game.Screens.OnlinePlay.Match private IBindable> managerUpdated; [Cached] - protected OnlinePlayBeatmapAvailabilityTracker BeatmapAvailabilityTracker { get; } + protected OnlinePlayBeatmapAvailabilityTracker BeatmapAvailabilityTracker { get; private set; } protected IBindable BeatmapAvailability => BeatmapAvailabilityTracker.Availability; - protected RoomSubScreen() - { - AddRangeInternal(new Drawable[] - { - BeatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker - { - SelectedItem = { BindTarget = SelectedItem } - }, - new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Depth = float.MinValue, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }, - Child = userModsSelectOverlay = new UserModSelectOverlay - { - SelectedMods = { BindTarget = UserMods }, - IsValidMod = _ => false - } - }, - }); - } + public readonly Room Room; + private readonly bool allowEdit; - protected override void ClearInternal(bool disposeChildren = true) => - throw new InvalidOperationException($"{nameof(RoomSubScreen)}'s children should not be cleared as it will remove required components"); + private ModSelectOverlay userModsSelectOverlay; + private RoomSettingsOverlay settingsOverlay; + private Drawable mainContent; + + /// + /// Creates a new . + /// + /// The . + /// Whether to allow editing room settings post-creation. + protected RoomSubScreen(Room room, bool allowEdit = true) + { + Room = room; + this.allowEdit = allowEdit; + + Padding = new MarginPadding { Top = Header.HEIGHT }; + + BeatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker + { + SelectedItem = { BindTarget = SelectedItem } + }; + + RoomId.BindTo(room.RoomID); + } [BackgroundDependencyLoader] private void load(AudioManager audio) { sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + + InternalChildren = new Drawable[] + { + BeatmapAvailabilityTracker, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 50) + }, + Content = new[] + { + // Padded main content (drawable room + main content) + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Bottom = 30 + }, + Children = new[] + { + mainContent = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 10) + }, + Content = new[] + { + new Drawable[] + { + new DrawableMatchRoom(Room, allowEdit) + { + OnEdit = () => settingsOverlay.Show(), + SelectedItem = { BindTarget = SelectedItem } + } + }, + null, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary. + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20), + Child = CreateMainContent(), + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = userModsSelectOverlay = new UserModSelectOverlay + { + SelectedMods = { BindTarget = UserMods }, + IsValidMod = _ => false + } + }, + } + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + // Resolves 1px masking errors between the settings overlay and the room panel. + Padding = new MarginPadding(-1), + Child = settingsOverlay = CreateRoomSettingsOverlay(Room) + } + }, + }, + }, + // Footer + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d") // Temporary. + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(5), + Child = CreateFooter() + }, + } + } + } + } + } + }; } protected override void LoadComplete() { base.LoadComplete(); + RoomId.BindValueChanged(id => + { + if (id.NewValue == null) + { + // A new room is being created. + // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. + mainContent.Hide(); + settingsOverlay.Show(); + } + else + { + mainContent.Show(); + settingsOverlay.Hide(); + } + }, true); + SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged)); managerUpdated = beatmapManager.ItemUpdated.GetBoundCopy(); @@ -105,14 +252,35 @@ namespace osu.Game.Screens.OnlinePlay.Match UserMods.BindValueChanged(_ => Scheduler.AddOnce(UpdateMods)); } + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + return new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) + { + Model = { Value = Room } + }; + } + public override bool OnBackButton() { + if (Room.RoomID.Value == null) + { + // room has not been created yet; exit immediately. + settingsOverlay.Hide(); + return base.OnBackButton(); + } + if (userModsSelectOverlay.State.Value == Visibility.Visible) { userModsSelectOverlay.Hide(); return true; } + if (settingsOverlay.State.Value == Visibility.Visible) + { + settingsOverlay.Hide(); + return true; + } + return base.OnBackButton(); } @@ -247,7 +415,23 @@ namespace osu.Game.Screens.OnlinePlay.Match track.Looping = false; } - private class UserModSelectOverlay : LocalPlayerModSelectOverlay + /// + /// Creates the main centred content. + /// + protected abstract Drawable CreateMainContent(); + + /// + /// Creates the footer content. + /// + protected abstract Drawable CreateFooter(); + + /// + /// Creates the room settings overlay. + /// + /// The room to change the settings of. + protected abstract RoomSettingsOverlay CreateRoomSettingsOverlay(Room room); + + public class UserModSelectButton : PurpleTriangleButton { } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs index cc51b5b691..e80923ed47 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs @@ -8,7 +8,7 @@ using osu.Game.Screens.OnlinePlay.Match.Components; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class CreateMultiplayerMatchButton : PurpleTriangleButton + public class CreateMultiplayerMatchButton : CreateRoomButton { private IBindable isConnected; private IBindable operationInProgress; @@ -22,8 +22,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [BackgroundDependencyLoader] private void load() { - Triangles.TriangleScale = 1.5f; - Text = "Create room"; isConnected = multiplayerClient.IsConnected.GetBoundCopy(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs new file mode 100644 index 0000000000..af0c50a848 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public class GameplayChatDisplay : MatchChatDisplay, IKeyBindingHandler + { + [Resolved] + private ILocalUserPlayInfo localUserInfo { get; set; } + + private IBindable localUserPlaying = new Bindable(); + + public override bool PropagatePositionalInputSubTree => !localUserPlaying.Value; + + public Bindable Expanded = new Bindable(); + + private readonly Bindable expandedFromTextboxFocus = new Bindable(); + + private const float height = 100; + + public override bool PropagateNonPositionalInputSubTree => true; + + public GameplayChatDisplay(Room room) + : base(room, leaveChannelOnDispose: false) + { + RelativeSizeAxes = Axes.X; + + Background.Alpha = 0.2f; + + Textbox.FocusLost = () => expandedFromTextboxFocus.Value = false; + } + + protected override bool OnHover(HoverEvent e) => true; // use UI mouse cursor. + + protected override void LoadComplete() + { + base.LoadComplete(); + + localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy(); + localUserPlaying.BindValueChanged(playing => + { + // for now let's never hold focus. this avoid misdirected gameplay keys entering chat. + // note that this is done within this callback as it triggers an un-focus as well. + Textbox.HoldFocus = false; + + // only hold focus (after sending a message) during breaks + Textbox.ReleaseFocusOnCommit = playing.NewValue; + }, true); + + Expanded.BindValueChanged(_ => updateExpandedState(), true); + expandedFromTextboxFocus.BindValueChanged(focus => + { + if (focus.NewValue) + updateExpandedState(); + else + { + // on finishing typing a message there should be a brief delay before hiding. + using (BeginDelayedSequence(600)) + updateExpandedState(); + } + }, true); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.ToggleChatFocus: + if (Textbox.HasFocus) + { + Schedule(() => Textbox.KillFocus()); + } + else + { + expandedFromTextboxFocus.Value = true; + + // schedule required to ensure the textbox has become present from above bindable update. + Schedule(() => Textbox.TakeFocus()); + } + + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + private void updateExpandedState() + { + if (Expanded.Value || expandedFromTextboxFocus.Value) + { + this.FadeIn(300, Easing.OutQuint); + this.ResizeHeightTo(height, 500, Easing.OutQuint); + } + else + { + this.FadeOut(300, Easing.OutQuint); + this.ResizeHeightTo(0, 500, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayMatchScoreDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayMatchScoreDisplay.cs new file mode 100644 index 0000000000..20a88545c5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayMatchScoreDisplay.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public class GameplayMatchScoreDisplay : MatchScoreDisplay + { + public Bindable Expanded = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + Scale = new Vector2(0.5f); + + Expanded.BindValueChanged(expandedChanged, true); + } + + private void expandedChanged(ValueChangedEvent expanded) + { + if (expanded.NewValue) + { + Score1Text.FadeIn(500, Easing.OutQuint); + Score2Text.FadeIn(500, Easing.OutQuint); + this.ResizeWidthTo(2, 500, Easing.OutQuint); + } + else + { + Score1Text.FadeOut(500, Easing.OutQuint); + Score2Text.FadeOut(500, Easing.OutQuint); + this.ResizeWidthTo(1, 500, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/BeatmapSelectionControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/BeatmapSelectionControl.cs index ebe63e26d6..35f30edf65 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/BeatmapSelectionControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/BeatmapSelectionControl.cs @@ -8,10 +8,11 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; using osu.Game.Online.API; using osu.Game.Screens.OnlinePlay.Match.Components; +using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class BeatmapSelectionControl : RoomSubScreenComposite + public class BeatmapSelectionControl : OnlinePlayComposite { [Resolved] private MultiplayerMatchSubScreen matchSubScreen { get; set; } @@ -35,6 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, + Spacing = new Vector2(5), Children = new Drawable[] { beatmapPanelContainer = new Container @@ -47,7 +49,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.X, Height = 40, Text = "Select beatmap", - Action = () => matchSubScreen.Push(new MultiplayerMatchSongSelect()), + Action = () => + { + if (matchSubScreen.IsCurrentScreen()) + matchSubScreen.Push(new MultiplayerMatchSongSelect(matchSubScreen.Room)); + }, Alpha = 0 } } @@ -68,6 +74,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }, true); } + public void BeginSelection() => selectButton.TriggerClick(); + private void updateBeatmap() { if (SelectedItem.Value == null) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs index d4f5428bfb..036e37ddfd 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs @@ -2,18 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public class MultiplayerMatchFooter : CompositeDrawable { - public const float HEIGHT = 50; private const float ready_button_width = 600; private const float spectate_button_width = 200; @@ -27,54 +22,42 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match set => spectateButton.OnSpectateClick = value; } - private readonly Drawable background; private readonly MultiplayerReadyButton readyButton; private readonly MultiplayerSpectateButton spectateButton; public MultiplayerMatchFooter() { - RelativeSizeAxes = Axes.X; - Height = HEIGHT; + RelativeSizeAxes = Axes.Both; - InternalChildren = new[] + InternalChild = new GridContainer { - background = new Box { RelativeSizeAxes = Axes.Both }, - new GridContainer + RelativeSizeAxes = Axes.Both, + Content = new[] { - RelativeSizeAxes = Axes.Both, - Content = new[] + new Drawable[] { - new Drawable[] + null, + spectateButton = new MultiplayerSpectateButton { - null, - spectateButton = new MultiplayerSpectateButton - { - RelativeSizeAxes = Axes.Both, - }, - null, - readyButton = new MultiplayerReadyButton - { - RelativeSizeAxes = Axes.Both, - }, - null - } - }, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(maxSize: spectate_button_width), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(maxSize: ready_button_width), - new Dimension() + RelativeSizeAxes = Axes.Both, + }, + null, + readyButton = new MultiplayerReadyButton + { + RelativeSizeAxes = Axes.Both, + }, + null } + }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(maxSize: spectate_button_width), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(maxSize: ready_button_width), + new Dimension() } }; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - background.Colour = Color4Extensions.FromHex(@"28242d"); - } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchHeader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchHeader.cs deleted file mode 100644 index bb351d06d3..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchHeader.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Online.API; -using osu.Game.Screens.OnlinePlay.Match.Components; -using osu.Game.Users.Drawables; -using osuTK; -using FontWeight = osu.Game.Graphics.FontWeight; -using OsuColour = osu.Game.Graphics.OsuColour; -using OsuFont = osu.Game.Graphics.OsuFont; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match -{ - public class MultiplayerMatchHeader : OnlinePlayComposite - { - public const float HEIGHT = 50; - - public Action OpenSettings; - - private UpdateableAvatar avatar; - private LinkFlowContainer hostText; - private Button openSettingsButton; - - [Resolved] - private IAPIProvider api { get; set; } - - public MultiplayerMatchHeader() - { - RelativeSizeAxes = Axes.X; - Height = HEIGHT; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - avatar = new UpdateableAvatar - { - Size = new Vector2(50), - Masking = true, - CornerRadius = 10, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30), - Current = { BindTarget = RoomName } - }, - hostText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 20)) - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - } - } - } - } - }, - openSettingsButton = new PurpleTriangleButton - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Size = new Vector2(150, HEIGHT), - Text = "Open settings", - Action = () => OpenSettings?.Invoke(), - Alpha = 0 - } - }; - - Host.BindValueChanged(host => - { - avatar.User = host.NewValue; - - hostText.Clear(); - - if (host.NewValue != null) - { - hostText.AddText("hosted by "); - hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); - } - - openSettingsButton.Alpha = host.NewValue?.Equals(api.LocalUser.Value) == true ? 1 : 0; - }, true); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index fe9979b161..80a5daa7c8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Graphics; @@ -25,19 +26,31 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerMatchSettingsOverlay : MatchSettingsOverlay + public class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay { - [BackgroundDependencyLoader] - private void load() + private MatchSettings settings; + + protected override OsuButton SubmitButton => settings.ApplyButton; + + [Resolved] + private OngoingOperationTracker ongoingOperationTracker { get; set; } + + protected override bool IsLoading => ongoingOperationTracker.InProgress.Value; + + public MultiplayerMatchSettingsOverlay(Room room) + : base(room) { - Child = Settings = new MatchSettings - { - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Y, - SettingsApplied = Hide - }; } + protected override void SelectBeatmap() => settings.SelectBeatmap(); + + protected override OnlinePlayComposite CreateSettings(Room room) => settings = new MatchSettings(room) + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Y, + SettingsApplied = Hide + }; + protected class MatchSettings : OnlinePlayComposite { private const float disabled_alpha = 0.2f; @@ -46,7 +59,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public OsuTextBox NameField, MaxParticipantsField; public RoomAvailabilityPicker AvailabilityPicker; - public GameTypePicker TypePicker; + public MatchTypePicker TypePicker; + public OsuTextBox PasswordTextBox; public TriangleButton ApplyButton; public OsuSpriteText ErrorText; @@ -55,15 +69,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private LoadingLayer loadingLayer; private BeatmapSelectionControl initialBeatmapControl; + public void SelectBeatmap() => initialBeatmapControl.BeginSelection(); + [Resolved] private IRoomManager manager { get; set; } [Resolved] private MultiplayerClient client { get; set; } - [Resolved] - private Bindable currentRoom { get; set; } - [Resolved] private Bindable beatmap { get; set; } @@ -78,6 +91,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [CanBeNull] private IDisposable applyingSettingsOperation; + private readonly Room room; + + public MatchSettings(Room room) + { + this.room = room; + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -93,7 +113,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[] @@ -138,6 +158,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, + LengthLimit = 100, }, }, new Section("Room visibility") @@ -150,7 +171,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }, new Section("Game type") { - Alpha = disabled_alpha, Child = new FillFlowContainer { AutoSizeAxes = Axes.Y, @@ -159,10 +179,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Spacing = new Vector2(7), Children = new Drawable[] { - TypePicker = new GameTypePicker + TypePicker = new MatchTypePicker { RelativeSizeAxes = Axes.X, - Enabled = { Value = false } }, typeLabel = new OsuSpriteText { @@ -193,12 +212,11 @@ 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, + LengthLimit = 255, }, }, } @@ -269,12 +287,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match loadingLayer = new LoadingLayer(true) }; - TypePicker.Current.BindValueChanged(type => typeLabel.Text = type.NewValue?.Name ?? string.Empty, true); + TypePicker.Current.BindValueChanged(type => typeLabel.Text = type.NewValue.GetLocalisableDescription(), true); RoomName.BindValueChanged(name => NameField.Text = name.NewValue, true); Availability.BindValueChanged(availability => AvailabilityPicker.Current.Value = availability.NewValue, true); 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,26 +326,27 @@ 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, matchType: TypePicker.Current.Value).ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) - onSuccess(currentRoom.Value); + onSuccess(room); else onError(t.Exception?.AsSingular().Message ?? "Error changing settings."); })); } else { - currentRoom.Value.Name.Value = NameField.Text; - currentRoom.Value.Availability.Value = AvailabilityPicker.Current.Value; - currentRoom.Value.Type.Value = TypePicker.Current.Value; + room.Name.Value = NameField.Text; + room.Availability.Value = AvailabilityPicker.Current.Value; + room.Type.Value = TypePicker.Current.Value; + room.Password.Value = PasswordTextBox.Current.Value; if (int.TryParse(MaxParticipantsField.Text, out int max)) - currentRoom.Value.MaxParticipants.Value = max; + room.MaxParticipants.Value = max; else - currentRoom.Value.MaxParticipants.Value = null; + room.MaxParticipants.Value = null; - manager?.CreateRoom(currentRoom.Value, onSuccess, onError); + manager?.CreateRoom(room, onSuccess, onError); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index f2dd9a6f25..b854b6d964 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -2,13 +2,13 @@ // 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; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Online.API; @@ -36,12 +36,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private IBindable operationInProgress; - private Sample sampleReadyCount; + private Sample sampleReady; + private Sample sampleReadyAll; + private Sample sampleUnready; private readonly ButtonWithTrianglesExposed button; private int countReady; + private ScheduledDelegate readySampleDelegate; + public MultiplayerReadyButton() { InternalChild = button = new ButtonWithTrianglesExposed @@ -55,10 +59,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [BackgroundDependencyLoader] private void load(AudioManager audio) { - sampleReadyCount = audio.Samples.Get(@"SongSelect/select-difficulty"); - operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy(); operationInProgress.BindValueChanged(_ => updateState()); + + sampleReady = audio.Samples.Get(@"Multiplayer/player-ready"); + sampleReadyAll = audio.Samples.Get(@"Multiplayer/player-ready-all"); + sampleUnready = audio.Samples.Get(@"Multiplayer/player-unready"); } protected override void OnRoomUpdated() @@ -72,25 +78,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}"; @@ -105,29 +106,34 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match break; } - bool enableButton = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value; + bool enableButton = 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; - if (newCountReady != countReady) - { - countReady = newCountReady; - Scheduler.AddOnce(playSound); - } - } - - private void playSound() - { - if (sampleReadyCount == null) + if (newCountReady == countReady) return; - var channel = sampleReadyCount.GetChannel(); - channel.Frequency.Value = 0.77f + countReady * 0.06f; - channel.Play(); + readySampleDelegate?.Cancel(); + readySampleDelegate = Schedule(() => + { + if (newCountReady > countReady) + { + if (newCountReady == newCountTotal) + sampleReadyAll?.Play(); + else + sampleReady?.Play(); + } + else if (newCountReady < countReady) + { + sampleUnready?.Play(); + } + + countReady = newCountReady; + }); } private void updateButtonColour(bool green) 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/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index dbac826954..58b5b7bbeb 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -2,11 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; @@ -25,48 +22,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.ChangeState(MultiplayerUserState.Idle); } - protected override void UpdatePollingRate(bool isIdle) - { - var multiplayerRoomManager = (MultiplayerRoomManager)RoomManager; - - if (!this.IsCurrentScreen()) - { - multiplayerRoomManager.TimeBetweenListingPolls.Value = 0; - multiplayerRoomManager.TimeBetweenSelectionPolls.Value = 0; - } - else - { - switch (CurrentSubScreen) - { - case LoungeSubScreen _: - multiplayerRoomManager.TimeBetweenListingPolls.Value = isIdle ? 120000 : 15000; - multiplayerRoomManager.TimeBetweenSelectionPolls.Value = isIdle ? 120000 : 15000; - break; - - // Don't poll inside the match or anywhere else. - default: - multiplayerRoomManager.TimeBetweenListingPolls.Value = 0; - multiplayerRoomManager.TimeBetweenSelectionPolls.Value = 0; - break; - } - } - - Logger.Log($"Polling adjusted (listing: {multiplayerRoomManager.TimeBetweenListingPolls.Value}, selection: {multiplayerRoomManager.TimeBetweenSelectionPolls.Value})"); - } - - protected override Room CreateNewRoom() => - new Room - { - Name = { Value = $"{API.LocalUser}'s awesome room" }, - Category = { Value = RoomCategory.Realtime } - }; - protected override string ScreenTitle => "Multiplayer"; protected override RoomManager CreateRoomManager() => new MultiplayerRoomManager(); protected override LoungeSubScreen CreateLounge() => new MultiplayerLoungeSubScreen(); - - protected override OsuButton CreateNewMultiplayerGameButton() => new CreateMultiplayerMatchButton(); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerFilterControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerFilterControl.cs deleted file mode 100644 index 37e0fd109a..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerFilterControl.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Screens.OnlinePlay.Lounge.Components; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer -{ - public class MultiplayerFilterControl : FilterControl - { - protected override FilterCriteria CreateCriteria() - { - var criteria = base.CreateCriteria(); - criteria.Category = "realtime"; - return criteria; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index 4d20652465..6c3dfe7382 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -1,10 +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.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +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; @@ -13,22 +19,80 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { public class MultiplayerLoungeSubScreen : LoungeSubScreen { - protected override FilterControl CreateFilterControl() => new MultiplayerFilterControl(); - - protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); + [Resolved] + private IAPIProvider api { get; set; } [Resolved] private MultiplayerClient client { get; set; } - public override void Open(Room room) + public override void OnResuming(IScreen last) { - if (!client.IsConnected.Value) + base.OnResuming(last); + + // Upon having left a room, we don't know whether we were the only participant, and whether the room is now closed as a result of leaving it. + // To work around this, temporarily remove the room and trigger an immediate listing poll. + if (last is MultiplayerMatchSubScreen match) + { + RoomManager.RemoveRoom(match.Room); + ListingPollingComponent.PollImmediately(); + } + } + + protected override FilterCriteria CreateFilterCriteria() + { + var criteria = base.CreateFilterCriteria(); + criteria.Category = @"realtime"; + return criteria; + } + + protected override OsuButton CreateNewRoomButton() => new CreateMultiplayerMatchButton(); + + protected override Room CreateNewRoom() => new Room + { + Name = { Value = $"{api.LocalUser}'s awesome room" }, + Type = { Value = MatchType.HeadToHead }, + }; + + protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); + + protected override ListingPollingComponent CreatePollingComponent() => new MultiplayerListingPollingComponent(); + + protected override void OpenNewRoom(Room room) + { + if (client?.IsConnected.Value != true) { Logger.Log("Not currently connected to the multiplayer server.", LoggingTarget.Runtime, LogLevel.Important); return; } - base.Open(room); + base.OpenNewRoom(room); + } + + private class MultiplayerListingPollingComponent : ListingPollingComponent + { + [Resolved] + private MultiplayerClient client { get; set; } + + private readonly IBindable isConnected = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { + isConnected.BindTo(client.IsConnected); + isConnected.BindValueChanged(c => Scheduler.AddOnce(() => + { + if (isConnected.Value && IsLoaded) + PollImmediately(); + }), true); + } + + protected override Task Poll() + { + if (!isConnected.Value) + return Task.CompletedTask; + + return base.Poll(); + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index 3733b85a5e..ad4bb90551 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -24,9 +24,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer /// /// Construct a new instance of multiplayer song select. /// + /// The room. /// An optional initial beatmap selection to perform. /// An optional initial ruleset selection to perform. - public MultiplayerMatchSongSelect(WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) + public MultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) + : base(room) { if (beatmap != null || ruleset != null) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 4b8c4422ec..544eac4127 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -48,213 +48,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } - private MultiplayerMatchSettingsOverlay settingsOverlay; - private readonly IBindable isConnected = new Bindable(); [CanBeNull] private IDisposable readyClickOperation; - private GridContainer mainContent; - public MultiplayerMatchSubScreen(Room room) + : base(room) { Title = room.RoomID.Value == null ? "New room" : room.Name.Value; Activity.Value = new UserActivity.InLobby(room); } - [BackgroundDependencyLoader] - private void load() - { - AddRangeInternal(new Drawable[] - { - mainContent = new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Horizontal = HORIZONTAL_OVERFLOW_PADDING + 55, - Vertical = 20 - }, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - }, - Content = new[] - { - new Drawable[] - { - new MultiplayerMatchHeader - { - OpenSettings = () => settingsOverlay.Show() - } - }, - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 400), - new Dimension(), - new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 600), - }, - Content = new[] - { - new Drawable[] - { - // Main left column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] { new ParticipantsListHeader() }, - new Drawable[] - { - new ParticipantsList - { - RelativeSizeAxes = Axes.Both - }, - } - } - }, - // Spacer - null, - // Main right column - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - new OverlinedHeader("Beatmap"), - new BeatmapSelectionControl { RelativeSizeAxes = Axes.X } - } - }, - UserModsSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 10 }, - Children = new Drawable[] - { - new OverlinedHeader("Extra mods"), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new PurpleTriangleButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } - } - } - } - } - } - } - } - } - } - }, - new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay { RelativeSizeAxes = Axes.Both } } - } - } - } - }, - } - } - }, - new Drawable[] - { - new MultiplayerMatchFooter - { - OnReadyClick = onReadyClick, - OnSpectateClick = onSpectateClick - } - } - }, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - } - }, - settingsOverlay = new MultiplayerMatchSettingsOverlay - { - RelativeSizeAxes = Axes.Both, - State = { Value = client.Room == null ? Visibility.Visible : Visibility.Hidden } - } - }); - - if (client.Room == null) - { - // A new room is being created. - // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. - mainContent.Hide(); - - settingsOverlay.State.BindValueChanged(visibility => - { - if (visibility.NewValue == Visibility.Hidden) - mainContent.Show(); - }, true); - } - } - protected override void LoadComplete() { base.LoadComplete(); @@ -271,10 +76,130 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer isConnected.BindValueChanged(connected => { if (!connected.NewValue) - Schedule(this.Exit); + handleRoomLost(); }, true); } + protected override Drawable CreateMainContent() => new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 400), + new Dimension(), + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 600), + }, + Content = new[] + { + new Drawable[] + { + // Main left column + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] { new ParticipantsListHeader() }, + new Drawable[] + { + new ParticipantsList + { + RelativeSizeAxes = Axes.Both + }, + } + } + }, + // Spacer + null, + // Main right column + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Beatmap") }, + new Drawable[] { new BeatmapSelectionControl { RelativeSizeAxes = Axes.X } }, + new[] + { + UserModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UserModSelectButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Text = "Select", + Action = ShowUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + }, + } + }, + } + }, + }, + new Drawable[] { new OverlinedHeader("Chat") { Margin = new MarginPadding { Vertical = 5 }, }, }, + new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } + }, + } + } + } + } + }, + }, + }; + + protected override Drawable CreateFooter() => new MultiplayerMatchFooter + { + OnReadyClick = onReadyClick, + OnSpectateClick = onSpectateClick + }; + + protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room); + protected override void UpdateMods() { if (SelectedItem.Value == null || client.LocalUser == null) @@ -291,26 +216,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private bool exitConfirmed; - public override bool OnBackButton() - { - if (client.Room == null) - { - // room has not been created yet; exit immediately. - return base.OnBackButton(); - } - - if (settingsOverlay.State.Value == Visibility.Visible) - { - settingsOverlay.Hide(); - return true; - } - - return base.OnBackButton(); - } - public override bool OnExiting(IScreen next) { - if (client.Room == null) + // 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); @@ -432,9 +342,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void onRoomUpdated() { + // may happen if the client is kicked or otherwise removed from the room. + if (client.Room == null) + { + handleRoomLost(); + return; + } + Scheduler.AddOnce(UpdateMods); } + private void handleRoomLost() => Schedule(() => + { + if (this.IsCurrentScreen()) + this.Exit(); + else + ValidForResume = false; + }); + private void onLoadRequested() { if (BeatmapAvailability.Value.State != DownloadState.LocallyAvailable) @@ -459,32 +384,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override Screen CreateGameplayScreen() { Debug.Assert(client.LocalUser != null); + Debug.Assert(client.Room != null); int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); + MultiplayerRoomUser[] users = userIds.Select(id => client.Room.Users.First(u => u.UserID == id)).ToArray(); switch (client.LocalUser.State) { case MultiplayerUserState.Spectating: - return new MultiSpectatorScreen(userIds); + return new MultiSpectatorScreen(users.Take(PlayerGrid.MAX_PLAYERS).ToArray()); default: - return new PlayerLoader(() => new MultiplayerPlayer(SelectedItem.Value, userIds)); + return new PlayerLoader(() => new MultiplayerPlayer(Room, SelectedItem.Value, users)); } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (client != null) - { - client.RoomUpdated -= onRoomUpdated; - client.LoadRequested -= onLoadRequested; - } - - modSettingChangeTracker?.Dispose(); - } - public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) { if (!this.IsCurrentScreen()) @@ -498,7 +412,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - this.Push(new MultiplayerMatchSongSelect(beatmap, ruleset)); + this.Push(new MultiplayerMatchSongSelect(Room, beatmap, ruleset)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client != null) + { + client.RoomUpdated -= onRoomUpdated; + client.LoadRequested -= onLoadRequested; + } + + modSettingChangeTracker?.Dispose(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 1bbe49a705..bd2f49a9e5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -3,9 +3,12 @@ using System; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; @@ -14,6 +17,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; +using osu.Game.Users; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer @@ -25,6 +29,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer // Disallow fails in multiplayer for now. protected override bool CheckModsAllowFailure() => false; + protected override UserActivity InitialActivity => new UserActivity.InMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); + [Resolved] private MultiplayerClient client { get; set; } @@ -34,31 +40,66 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private MultiplayerGameplayLeaderboard leaderboard; - private readonly int[] userIds; + private readonly MultiplayerRoomUser[] users; private LoadingLayer loadingDisplay; + private FillFlowContainer leaderboardFlow; /// /// Construct a multiplayer player. /// + /// The room. /// The playlist item to be played. - /// The users which are participating in this game. - public MultiplayerPlayer(PlaylistItem playlistItem, int[] userIds) - : base(playlistItem, new PlayerConfiguration + /// The users which are participating in this game. + public MultiplayerPlayer(Room room, PlaylistItem playlistItem, MultiplayerRoomUser[] users) + : base(room, playlistItem, new PlayerConfiguration { AllowPause = false, AllowRestart = false, AllowSkipping = false, }) { - this.userIds = userIds; + this.users = users; } [BackgroundDependencyLoader] private void load() { + if (!LoadedBeatmapSuccessfully) + return; + + HUDOverlay.Add(leaderboardFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5) + }); + // todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area. - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(ScoreProcessor, userIds), HUDOverlay.Add); + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(ScoreProcessor, users), l => + { + if (!LoadedBeatmapSuccessfully) + return; + + ((IBindable)leaderboard.Expanded).BindTo(HUDOverlay.ShowHud); + + leaderboardFlow.Insert(0, l); + + if (leaderboard.TeamScores.Count >= 2) + { + LoadComponentAsync(new GameplayMatchScoreDisplay + { + Team1Score = { BindTarget = leaderboard.TeamScores.First().Value }, + Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value }, + Expanded = { BindTarget = HUDOverlay.ShowHud }, + }, scoreDisplay => leaderboardFlow.Insert(1, scoreDisplay)); + } + }); + + LoadComponentAsync(new GameplayChatDisplay(Room) + { + Expanded = { BindTarget = HUDOverlay.ShowHud }, + }, chat => leaderboardFlow.Insert(2, chat)); HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue }); } @@ -67,6 +108,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.LoadAsyncComplete(); + if (!LoadedBeatmapSuccessfully) + return; + if (!ValidForResume) return; // token retrieval may have failed. @@ -92,13 +136,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Debug.Assert(client.Room != null); } - protected override void LoadComplete() - { - base.LoadComplete(); - - ((IBindable)leaderboard.Expanded).BindTo(IsBreakTime); - } - protected override void StartGameplay() { // block base call, but let the server know we are ready to start. @@ -118,6 +155,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override void Update() { base.Update(); + + if (!LoadedBeatmapSuccessfully) + return; + adjustLeaderboardPosition(); } @@ -125,9 +166,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); + leaderboardFlow.Position = new Vector2(padding, padding + HUDOverlay.TopScoringElementsHeight); } private void onMatchStarted() => Scheduler.Add(() => @@ -151,8 +190,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override ResultsScreen CreateResults(ScoreInfo score) { - Debug.Assert(RoomId.Value != null); - return new MultiplayerResultsScreen(score, RoomId.Value.Value, PlaylistItem); + Debug.Assert(Room.RoomID.Value != null); + + return leaderboard.TeamScores.Count == 2 + ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, leaderboard.TeamScores) + : new MultiplayerResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index d334c618f5..0f256160eb 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -20,9 +20,38 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.LoadComplete(); Client.RoomUpdated += OnRoomUpdated; + + Client.UserLeft += UserLeft; + Client.UserKicked += UserKicked; + Client.UserJoined += UserJoined; + OnRoomUpdated(); } + /// + /// Invoked when a user has joined the room. + /// + /// The user. + protected virtual void UserJoined(MultiplayerRoomUser user) + { + } + + /// + /// Invoked when a user has been kicked from the room (including the local user). + /// + /// The user. + protected virtual void UserKicked(MultiplayerRoomUser user) + { + } + + /// + /// Invoked when a user has left the room. + /// + /// The user. + protected virtual void UserLeft(MultiplayerRoomUser user) + { + } + /// /// Invoked when any change occurs to the multiplayer room. /// @@ -33,7 +62,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override void Dispose(bool isDisposing) { if (Client != null) + { + Client.UserLeft -= UserLeft; + Client.UserKicked -= UserKicked; + Client.UserJoined -= UserJoined; Client.RoomUpdated -= OnRoomUpdated; + } base.Dispose(isDisposing); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs index 8526196902..2d94b2328d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs @@ -2,11 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Logging; using osu.Game.Online.Multiplayer; @@ -21,26 +18,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient multiplayerClient { get; set; } - public readonly Bindable TimeBetweenListingPolls = new Bindable(); - public readonly Bindable TimeBetweenSelectionPolls = new Bindable(); - private readonly IBindable isConnected = new Bindable(); - private readonly Bindable allowPolling = new Bindable(); - - private ListingPollingComponent listingPollingComponent; - - protected override void LoadComplete() - { - base.LoadComplete(); - - isConnected.BindTo(multiplayerClient.IsConnected); - isConnected.BindValueChanged(_ => Scheduler.AddOnce(updatePolling)); - JoinedRoom.BindValueChanged(_ => Scheduler.AddOnce(updatePolling), true); - } - 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 +37,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() @@ -64,26 +45,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (JoinedRoom.Value == null) return; - var joinedRoom = JoinedRoom.Value; - base.PartRoom(); - multiplayerClient.LeaveRoom(); - - // Todo: This is not the way to do this. Basically when we're the only participant and the room closes, there's no way to know if this is actually the case. - // This is delayed one frame because upon exiting the match subscreen, multiplayer updates the polling rate and messes with polling. - Schedule(() => - { - RemoveRoom(joinedRoom); - listingPollingComponent.PollImmediately(); - }); } - 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)); @@ -99,70 +69,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } }); } - - private void updatePolling() - { - if (!isConnected.Value) - ClearRooms(); - - // Don't poll when not connected or when a room has been joined. - allowPolling.Value = isConnected.Value && JoinedRoom.Value == null; - } - - protected override IEnumerable CreatePollingComponents() => new RoomPollingComponent[] - { - listingPollingComponent = new MultiplayerListingPollingComponent - { - TimeBetweenPolls = { BindTarget = TimeBetweenListingPolls }, - AllowPolling = { BindTarget = allowPolling } - }, - new MultiplayerSelectionPollingComponent - { - TimeBetweenPolls = { BindTarget = TimeBetweenSelectionPolls }, - AllowPolling = { BindTarget = allowPolling } - } - }; - - private class MultiplayerListingPollingComponent : ListingPollingComponent - { - public readonly IBindable AllowPolling = new Bindable(); - - protected override void LoadComplete() - { - base.LoadComplete(); - - AllowPolling.BindValueChanged(allowPolling => - { - if (!allowPolling.NewValue) - return; - - if (IsLoaded) - PollImmediately(); - }); - } - - protected override Task Poll() => !AllowPolling.Value ? Task.CompletedTask : base.Poll(); - } - - private class MultiplayerSelectionPollingComponent : SelectionPollingComponent - { - public readonly IBindable AllowPolling = new Bindable(); - - protected override void LoadComplete() - { - base.LoadComplete(); - - AllowPolling.BindValueChanged(allowPolling => - { - if (!allowPolling.NewValue) - return; - - if (IsLoaded) - PollImmediately(); - }); - } - - protected override Task Poll() => !AllowPolling.Value ? Task.CompletedTask : base.Poll(); - } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerTeamResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerTeamResultsScreen.cs new file mode 100644 index 0000000000..14a779dedf --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerTeamResultsScreen.cs @@ -0,0 +1,152 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online.Rooms; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public class MultiplayerTeamResultsScreen : MultiplayerResultsScreen + { + private readonly SortedDictionary teamScores; + + private Container winnerBackground; + private Drawable winnerText; + + public MultiplayerTeamResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem, SortedDictionary teamScores) + : base(score, roomId, playlistItem) + { + if (teamScores.Count != 2) + throw new NotSupportedException(@"This screen currently only supports 2 teams"); + + this.teamScores = teamScores; + } + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + const float winner_background_half_height = 250; + + VerticalScrollContent.Anchor = VerticalScrollContent.Origin = Anchor.TopCentre; + VerticalScrollContent.Scale = new Vector2(0.9f); + VerticalScrollContent.Y = 75; + + var redScore = teamScores.First().Value; + var blueScore = teamScores.Last().Value; + + LocalisableString winner; + Colour4 winnerColour; + + int comparison = redScore.Value.CompareTo(blueScore.Value); + + if (comparison < 0) + { + // team name should eventually be coming from the multiplayer match state. + winner = MultiplayerTeamResultsScreenStrings.TeamWins(@"Blue"); + winnerColour = colours.TeamColourBlue; + } + else if (comparison > 0) + { + // team name should eventually be coming from the multiplayer match state. + winner = MultiplayerTeamResultsScreenStrings.TeamWins(@"Red"); + winnerColour = colours.TeamColourRed; + } + else + { + winner = MultiplayerTeamResultsScreenStrings.TheTeamsAreTied; + winnerColour = Colour4.White.Opacity(0.5f); + } + + AddRangeInternal(new Drawable[] + { + new MatchScoreDisplay + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Team1Score = { BindTarget = redScore }, + Team2Score = { BindTarget = blueScore }, + }, + winnerBackground = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.X, + Height = winner_background_half_height, + Anchor = Anchor.Centre, + Origin = Anchor.BottomCentre, + Colour = ColourInfo.GradientVertical(Colour4.Black.Opacity(0), Colour4.Black.Opacity(0.4f)) + }, + new Box + { + RelativeSizeAxes = Axes.X, + Height = winner_background_half_height, + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Colour = ColourInfo.GradientVertical(Colour4.Black.Opacity(0.4f), Colour4.Black.Opacity(0)) + } + } + }, + (winnerText = new OsuSpriteText + { + Alpha = 0, + Font = OsuFont.Torus.With(size: 80, weight: FontWeight.Bold), + Text = winner, + Blending = BlendingParameters.Additive + }).WithEffect(new GlowEffect + { + Colour = winnerColour, + }).With(e => + { + e.Anchor = Anchor.Centre; + e.Origin = Anchor.Centre; + }) + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + using (BeginDelayedSequence(300)) + { + const double fade_in_duration = 600; + + winnerText.FadeInFromZero(fade_in_duration, Easing.InQuint); + winnerBackground.FadeInFromZero(fade_in_duration, Easing.InQuint); + + winnerText + .ScaleTo(10) + .ScaleTo(1, 600, Easing.InQuad) + .Then() + .ScaleTo(1.02f, 1600, Easing.OutQuint) + .FadeOut(5000, Easing.InQuad); + winnerBackground.Delay(2200).FadeOut(2000); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index f4a334e9d3..6f8c735b6e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -37,10 +37,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private RulesetStore rulesets { get; set; } private SpriteIcon crown; + private OsuSpriteText userRankText; private ModDisplay userModsDisplay; private StateDisplay userStateDisplay; + private IconButton kickButton; + public ParticipantPanel(MultiplayerRoomUser user) { User = user; @@ -56,99 +59,117 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants var backgroundColour = Color4Extensions.FromHex("#33413C"); - InternalChildren = new Drawable[] + InternalChild = new GridContainer { - crown = new SpriteIcon + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Solid.Crown, - Size = new Vector2(14), - Colour = Color4Extensions.FromHex("#F7E65D"), - Alpha = 0 + new Dimension(GridSizeMode.Absolute, 18), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), }, - new Container + Content = new[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 24 }, - Child = new Container + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 5, - Children = new Drawable[] + crown = new SpriteIcon { - new Box + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.Crown, + Size = new Vector2(14), + Colour = Color4Extensions.FromHex("#F7E65D"), + Alpha = 0 + }, + new TeamDisplay(User), + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = backgroundColour - }, - new UserCoverBackground - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Both, - Width = 0.75f, - User = user, - Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0), Color4.White.Opacity(0.25f)) - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Spacing = new Vector2(10), - Direction = FillDirection.Horizontal, - Children = new Drawable[] + new Box { - new UpdateableAvatar + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + new UserCoverBackground + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + Width = 0.75f, + User = user, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0), Color4.White.Opacity(0.25f)) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Spacing = new Vector2(10), + Direction = FillDirection.Horizontal, + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - User = user - }, - new UpdateableFlag - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(30, 20), - Country = user?.Country - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), - Text = user?.Username - }, - userRankText = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 14), + new UpdateableAvatar + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + User = user + }, + new UpdateableFlag + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(30, 20), + Country = user?.Country + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), + Text = user?.Username + }, + userRankText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 14), + } } - } - }, - new Container - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Right = 70 }, - Child = userModsDisplay = new ModDisplay + }, + new Container { - Scale = new Vector2(0.5f), - ExpansionMode = ExpansionMode.AlwaysContracted, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Right = 70 }, + Child = userModsDisplay = new ModDisplay + { + Scale = new Vector2(0.5f), + ExpansionMode = ExpansionMode.AlwaysContracted, + } + }, + userStateDisplay = new StateDisplay + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 10 }, } - }, - userStateDisplay = new StateDisplay - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding { Right = 10 }, } - } - } + }, + kickButton = new KickButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Margin = new MarginPadding(4), + Action = () => Client.KickUser(User.UserID), + }, + }, } }; } @@ -157,7 +178,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { base.OnRoomUpdated(); - if (Room == null) + if (Room == null || Client.LocalUser == null) return; const double fade_time = 50; @@ -169,6 +190,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); + if (Client.IsHost && !User.Equals(Client.LocalUser)) + kickButton.FadeIn(fade_time); + else + kickButton.FadeOut(fade_time); + if (Room.Host?.Equals(User) == true) crown.FadeIn(fade_time); else @@ -201,13 +227,36 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants new OsuMenuItem("Give host", MenuItemType.Standard, () => { // Ensure the local user is still host. - if (Room.Host?.UserID != api.LocalUser.Value.Id) + if (!Client.IsHost) return; Client.TransferHost(targetUser); + }), + new OsuMenuItem("Kick", MenuItemType.Destructive, () => + { + // Ensure the local user is still host. + if (!Client.IsHost) + return; + + Client.KickUser(targetUser); }) }; } } + + public class KickButton : IconButton + { + public KickButton() + { + Icon = FontAwesome.Solid.UserTimes; + TooltipText = "Kick"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + IconHoverColour = colours.Red; + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index 3759e45f18..2ad64e115e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -3,10 +3,13 @@ using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; +using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants @@ -15,8 +18,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { private FillFlowContainer panels; + private Sample userJoinSample; + private Sample userLeftSample; + private Sample userKickedSample; + [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { InternalChild = new OsuContextMenuContainer { @@ -34,6 +41,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } } }; + + userJoinSample = audio.Samples.Get(@"Multiplayer/player-joined"); + userLeftSample = audio.Samples.Get(@"Multiplayer/player-left"); + userKickedSample = audio.Samples.Get(@"Multiplayer/player-kicked"); + } + + protected override void UserJoined(MultiplayerRoomUser user) + { + base.UserJoined(user); + + userJoinSample?.Play(); + } + + protected override void UserLeft(MultiplayerRoomUser user) + { + base.UserLeft(user); + + userLeftSample?.Play(); + } + + protected override void UserKicked(MultiplayerRoomUser user) + { + base.UserKicked(user); + + userKickedSample?.Play(); } protected override void OnRoomUpdated() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs new file mode 100644 index 0000000000..833fbd6605 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -0,0 +1,142 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants +{ + internal class TeamDisplay : MultiplayerRoomComposite + { + private readonly MultiplayerRoomUser user; + + private Drawable box; + + private Sample sampleTeamSwap; + + [Resolved] + private OsuColour colours { get; set; } + + public TeamDisplay(MultiplayerRoomUser user) + { + this.user = user; + + RelativeSizeAxes = Axes.Y; + Width = 15; + + Margin = new MarginPadding { Horizontal = 3 }; + + Alpha = 0; + Scale = new Vector2(0, 1); + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + box = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + Masking = true, + Scale = new Vector2(0, 1), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + + if (Client.LocalUser?.Equals(user) == true) + { + InternalChild = new OsuClickableContainer + { + RelativeSizeAxes = Axes.Both, + TooltipText = "Change team", + Action = changeTeam, + Child = box + }; + } + else + { + InternalChild = box; + } + + sampleTeamSwap = audio.Samples.Get(@"Multiplayer/team-swap"); + } + + private void changeTeam() + { + Client.SendMatchRequest(new ChangeTeamRequest + { + TeamID = ((Client.LocalUser?.MatchState as TeamVersusUserState)?.TeamID + 1) % 2 ?? 0, + }); + } + + private int? displayedTeam; + + protected override void OnRoomUpdated() + { + base.OnRoomUpdated(); + + // we don't have a way of knowing when an individual user's state has updated, so just handle on RoomUpdated for now. + + var userRoomState = Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState; + + const double duration = 400; + + int? newTeam = (userRoomState as TeamVersusUserState)?.TeamID; + + if (newTeam == displayedTeam) + return; + + // only play the sample if an already valid team changes to another valid team. + // this avoids playing a sound for each user if the match type is changed to/from a team mode. + if (newTeam != null && displayedTeam != null) + sampleTeamSwap?.Play(); + + displayedTeam = newTeam; + + if (displayedTeam != null) + { + box.FadeColour(getColourForTeam(displayedTeam.Value), duration, Easing.OutQuint); + box.ScaleTo(new Vector2(box.Scale.X < 0 ? 1 : -1, 1), duration, Easing.OutQuint); + + this.ScaleTo(Vector2.One, duration, Easing.OutQuint); + this.FadeIn(duration); + } + else + { + this.ScaleTo(new Vector2(0, 1), duration, Easing.OutQuint); + this.FadeOut(duration); + } + } + + private ColourInfo getColourForTeam(int id) + { + switch (id) + { + default: + return colours.Red; + + case 1: + return colours.Blue; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs index 94278a47b6..b8f47c16ff 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs @@ -61,7 +61,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate playerClocks.Add(clock); } - public void RemovePlayerClock(ISpectatorPlayerClock clock) => playerClocks.Remove(clock); + public void RemovePlayerClock(ISpectatorPlayerClock clock) + { + playerClocks.Remove(clock); + clock.Stop(); + } protected override void Update() { @@ -130,6 +134,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; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs index ab3ead68b5..1614828a78 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs @@ -4,6 +4,7 @@ using System; using JetBrains.Annotations; using osu.Framework.Timing; +using osu.Game.Online.Multiplayer; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; @@ -11,15 +12,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { public class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard { - public MultiSpectatorLeaderboard([NotNull] ScoreProcessor scoreProcessor, int[] userIds) - : base(scoreProcessor, userIds) + public MultiSpectatorLeaderboard([NotNull] ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) + : base(scoreProcessor, users) { } 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,12 +28,12 @@ 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; } - protected override TrackedUserData CreateUserData(int userId, ScoreProcessor scoreProcessor) => new SpectatingTrackedUserData(userId, scoreProcessor); + protected override TrackedUserData CreateUserData(MultiplayerRoomUser user, ScoreProcessor scoreProcessor) => new SpectatingTrackedUserData(user, scoreProcessor); protected override void Update() { @@ -47,8 +48,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate [CanBeNull] public IClock Clock; - public SpectatingTrackedUserData(int userId, ScoreProcessor scoreProcessor) - : base(userId, scoreProcessor) + public SpectatingTrackedUserData(MultiplayerRoomUser user, ScoreProcessor scoreProcessor) + : base(user, scoreProcessor) { } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index 2c157b0564..ececa1e497 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -25,7 +25,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// The score containing the player's replay. /// The clock controlling the gameplay running state. public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock) - : base(score) + : base(score, new PlayerConfiguration { AllowUserInteraction = false }) { this.spectatorPlayerClock = spectatorPlayerClock; } @@ -34,6 +34,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate private void load() { spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames); + + HUDOverlay.PlayerSettingsOverlay.Expire(); + HUDOverlay.HoldToQuit.Expire(); } protected override void UpdateAfterChildren() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs index 5a1d28e9c4..14bd8fa6dc 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs @@ -3,6 +3,7 @@ using System; using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; @@ -19,6 +20,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { } + [BackgroundDependencyLoader] + private void load() + { + PlayerSettings.Expire(); + } + protected override void LogoArriving(OsuLogo logo, bool resuming) { } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 2a2759e0dd..7bf8ce0e1a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -8,9 +8,11 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Spectate; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate @@ -24,13 +26,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate 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; + public override bool? AllowTrackAdjustments => false; /// /// Whether all spectating players have finished loading. /// public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true); + [Resolved] + private OsuColour colours { get; set; } + [Resolved] private SpectatorClient spectatorClient { get; set; } @@ -45,20 +50,26 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate private PlayerArea currentAudioSource; private bool canStartMasterClock; + private readonly MultiplayerRoomUser[] users; + /// /// Creates a new . /// - /// The players to spectate. - public MultiSpectatorScreen(int[] userIds) - : base(userIds.Take(PlayerGrid.MAX_PLAYERS).ToArray()) + /// The players to spectate. + public MultiSpectatorScreen(MultiplayerRoomUser[] users) + : base(users.Select(u => u.UserID).ToArray()) { - instances = new PlayerArea[UserIds.Count]; + this.users = users; + + instances = new PlayerArea[Users.Count]; } [BackgroundDependencyLoader] private void load() { Container leaderboardContainer; + Container scoreDisplayContainer; + masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0); InternalChildren = new[] @@ -67,28 +78,44 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate masterClockContainer.WithChild(new GridContainer { RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, Content = new[] { new Drawable[] { - leaderboardContainer = new Container + scoreDisplayContainer = new Container { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y }, - grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] + { + new Drawable[] + { + leaderboardContainer = new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X + }, + grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } + } + } + } } } }) }; - for (int i = 0; i < UserIds.Count; i++) + for (int i = 0; i < Users.Count; i++) { - grid.Add(instances[i] = new PlayerArea(UserIds[i], masterClockContainer.GameplayClock)); + grid.Add(instances[i] = new PlayerArea(Users[i], masterClockContainer.GameplayClock)); syncManager.AddPlayerClock(instances[i].GameplayClock); } @@ -97,7 +124,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate var scoreProcessor = Ruleset.Value.CreateInstance().CreateScoreProcessor(); scoreProcessor.ApplyBeatmap(playableBeatmap); - LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, UserIds.ToArray()) + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, users) { Expanded = { Value = true }, Anchor = Anchor.CentreLeft, @@ -108,6 +135,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate leaderboard.AddClock(instance.UserId, instance.GameplayClock); leaderboardContainer.Add(leaderboard); + + if (leaderboard.TeamScores.Count == 2) + { + LoadComponentAsync(new MatchScoreDisplay + { + Team1Score = { BindTarget = leaderboard.TeamScores.First().Value }, + Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value }, + }, scoreDisplayContainer.Add); + } }); } @@ -177,12 +213,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { } - protected override void StartGameplay(int userId, GameplayState gameplayState) - => instances.Single(i => i.UserId == userId).LoadScore(gameplayState.Score); + protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState) + => instances.Single(i => i.UserId == userId).LoadScore(spectatorGameplayState.Score); protected override void EndGameplay(int userId) { RemoveUser(userId); + + var instance = instances.Single(i => i.UserId == userId); + + instance.FadeColour(colours.Gray4, 400, Easing.OutQuint); + syncManager.RemovePlayerClock(instance.GameplayClock); leaderboard.RemoveClock(userId); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index 95ccc08608..c3190cd845 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -84,7 +84,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate Score = score; - gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.Beatmap), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) + gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.BeatmapInfo), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) { RelativeSizeAxes = Axes.Both, Child = stack = new OsuScreenStack() diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs index eb0b23f13f..aa971864ef 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs @@ -30,11 +30,14 @@ namespace osu.Game.Screens.OnlinePlay protected Bindable Status { get; private set; } [Resolved(typeof(Room))] - protected Bindable Type { get; private set; } + protected Bindable Type { get; private set; } [Resolved(typeof(Room))] protected BindableList Playlist { get; private set; } + [Resolved(typeof(Room))] + protected Bindable Category { get; private set; } + [Resolved(typeof(Room))] protected BindableList RecentParticipants { get; private set; } @@ -56,11 +59,17 @@ 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; } + [Resolved(CanBeNull = true)] + private IBindable subScreenSelectedItem { get; set; } + /// - /// The currently selected item in the , or the first item from + /// The currently selected item in the , or the last item from /// if this is not within a . /// protected readonly Bindable SelectedItem = new Bindable(); @@ -69,12 +78,13 @@ namespace osu.Game.Screens.OnlinePlay { base.LoadComplete(); + subScreenSelectedItem?.BindValueChanged(_ => UpdateSelectedItem()); Playlist.BindCollectionChanged((_, __) => UpdateSelectedItem(), true); } protected virtual void UpdateSelectedItem() - { - SelectedItem.Value = Playlist.FirstOrDefault(); - } + => SelectedItem.Value = RoomID.Value == null || subScreenSelectedItem == null + ? Playlist.LastOrDefault() + : subScreenSelectedItem.Value; } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index ceee002c6e..fc20b21b60 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -1,60 +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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Input; using osu.Game.Online.API; -using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.Menu; 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; using osu.Game.Users; -using osuTK; namespace osu.Game.Screens.OnlinePlay { [Cached] public abstract class OnlinePlayScreen : OsuScreen, IHasSubScreenStack { - public override bool CursorVisible => (screenStack.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true; + [Cached] + protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + 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 readonly OsuButton createButton; - private readonly LoungeSubScreen loungeSubScreen; - private readonly ScreenStack screenStack; - - private readonly IBindable isIdle = new BindableBool(); + private MultiplayerWaveContainer waves; + private LoungeSubScreen loungeSubScreen; + private ScreenStack screenStack; [Cached(Type = typeof(IRoomManager))] protected RoomManager RoomManager { get; private set; } [Cached] - private readonly Bindable selectedRoom = new Bindable(); - - [Cached] - 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; } @@ -68,9 +52,6 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(CanBeNull = true)] private OsuLogo logo { get; set; } - private readonly Drawable header; - private readonly Drawable headerBackground; - protected OnlinePlayScreen() { Anchor = Anchor.Centre; @@ -78,93 +59,25 @@ namespace osu.Game.Screens.OnlinePlay RelativeSizeAxes = Axes.Both; Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; - var backgroundColour = Color4Extensions.FromHex(@"3e3a44"); + RoomManager = CreateRoomManager(); + } + private readonly IBindable apiState = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { InternalChild = waves = new MultiplayerWaveContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = backgroundColour, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = Header.HEIGHT }, - Children = new[] - { - header = new Container - { - RelativeSizeAxes = Axes.X, - Height = 400, - Children = new[] - { - headerBackground = new Container - { - RelativeSizeAxes = Axes.Both, - Width = 1.25f, - Masking = true, - Children = new Drawable[] - { - new HeaderBackgroundSprite - { - RelativeSizeAxes = Axes.X, - Height = 400 // Keep a static height so the header doesn't change as it's resized between subscreens - }, - } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = -1 }, // 1px padding to avoid a 1px gap due to masking - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(backgroundColour.Opacity(0.5f), backgroundColour) - }, - } - } - }, - screenStack = new OnlinePlaySubScreenStack { RelativeSizeAxes = Axes.Both } - } - }, + screenStack = new OnlinePlaySubScreenStack { RelativeSizeAxes = Axes.Both }, new Header(ScreenTitle, screenStack), - createButton = CreateNewMultiplayerGameButton().With(button => - { - button.Anchor = Anchor.TopRight; - button.Origin = Anchor.TopRight; - button.Size = new Vector2(150, Header.HEIGHT - 20); - button.Margin = new MarginPadding - { - Top = 10, - Right = 10 + HORIZONTAL_OVERFLOW_PADDING, - }; - 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); } private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => @@ -179,17 +92,15 @@ namespace osu.Game.Screens.OnlinePlay protected override void LoadComplete() { base.LoadComplete(); - isIdle.BindValueChanged(idle => UpdatePollingRate(idle.NewValue), true); - } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new CachedModelDependencyContainer(base.CreateChildDependencies(parent)); - dependencies.Model.BindTo(selectedRoom); - return dependencies; - } + screenStack.ScreenPushed += screenPushed; + screenStack.ScreenExited += screenExited; - protected abstract void UpdatePollingRate(bool isIdle); + screenStack.Push(loungeSubScreen = CreateLounge()); + + apiState.BindTo(API.State); + apiState.BindValueChanged(onlineStateChanged, true); + } private void forcefullyExit() { @@ -222,10 +133,10 @@ namespace osu.Game.Screens.OnlinePlay this.FadeIn(250); this.ScaleTo(1, 250, Easing.OutSine); - screenStack.CurrentScreen?.OnResuming(last); - base.OnResuming(last); + Debug.Assert(screenStack.CurrentScreen != null); + screenStack.CurrentScreen.OnResuming(last); - UpdatePollingRate(isIdle.Value); + base.OnResuming(last); } public override void OnSuspending(IScreen next) @@ -233,14 +144,14 @@ namespace osu.Game.Screens.OnlinePlay this.ScaleTo(1.1f, 250, Easing.InSine); this.FadeOut(250); - screenStack.CurrentScreen?.OnSuspending(next); - - UpdatePollingRate(isIdle.Value); + Debug.Assert(screenStack.CurrentScreen != null); + screenStack.CurrentScreen.OnSuspending(next); } public override bool OnExiting(IScreen next) { - if (screenStack.CurrentScreen?.OnExiting(next) == true) + var subScreen = screenStack.CurrentScreen as Drawable; + if (subScreen?.IsLoaded == true && screenStack.CurrentScreen.OnExiting(next)) return true; RoomManager.PartRoom(); @@ -278,18 +189,6 @@ namespace osu.Game.Screens.OnlinePlay logo.Delay(WaveContainer.DISAPPEAR_DURATION / 2).FadeOut(); } - /// - /// Creates and opens the newly-created room. - /// - /// An optional template to use when creating the room. - public void OpenNewRoom(Room room = null) => loungeSubScreen.Open(room ?? CreateNewRoom()); - - /// - /// Creates a new room. - /// - /// The created . - protected abstract Room CreateNewRoom(); - private void screenPushed(IScreen lastScreen, IScreen newScreen) { subScreenChanged(lastScreen, newScreen); @@ -305,39 +204,21 @@ namespace osu.Game.Screens.OnlinePlay private void subScreenChanged(IScreen lastScreen, IScreen newScreen) { - switch (newScreen) - { - case LoungeSubScreen _: - header.Delay(OnlinePlaySubScreen.RESUME_TRANSITION_DELAY).ResizeHeightTo(400, OnlinePlaySubScreen.APPEAR_DURATION, Easing.OutQuint); - headerBackground.MoveToX(0, OnlinePlaySubScreen.X_MOVE_DURATION, Easing.OutQuint); - break; - - case RoomSubScreen _: - header.ResizeHeightTo(135, OnlinePlaySubScreen.APPEAR_DURATION, Easing.OutQuint); - headerBackground.MoveToX(-OnlinePlaySubScreen.X_SHIFT, OnlinePlaySubScreen.X_MOVE_DURATION, Easing.OutQuint); - break; - } - if (lastScreen is IOsuScreen lastOsuScreen) Activity.UnbindFrom(lastOsuScreen.Activity); if (newScreen is IOsuScreen newOsuScreen) ((IBindable)Activity).BindTo(newOsuScreen.Activity); - - UpdatePollingRate(isIdle.Value); - createButton.FadeTo(newScreen is LoungeSubScreen ? 1 : 0, 200); } protected IScreen CurrentSubScreen => screenStack.CurrentScreen; protected abstract string ScreenTitle { get; } - protected abstract RoomManager CreateRoomManager(); + protected virtual RoomManager CreateRoomManager() => new RoomManager(); protected abstract LoungeSubScreen CreateLounge(); - protected abstract OsuButton CreateNewMultiplayerGameButton(); - private class MultiplayerWaveContainer : WaveContainer { protected override bool StartHidden => true; @@ -351,16 +232,6 @@ namespace osu.Game.Screens.OnlinePlay } } - private class HeaderBackgroundSprite : OnlinePlayBackgroundSprite - { - protected override UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new BackgroundSprite { RelativeSizeAxes = Axes.Both }; - - private class BackgroundSprite : UpdateableBeatmapBackgroundSprite - { - protected override double TransformDuration => 200; - } - } - ScreenStack IHasSubScreenStack.SubScreenStack => screenStack; } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 2c46f76737..4bc0b55433 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -17,6 +17,7 @@ using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; +using osu.Game.Users; using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay @@ -32,6 +33,8 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room), nameof(Room.Playlist))] protected BindableList Playlist { get; private set; } + protected override UserActivity InitialActivity => new UserActivity.InLobby(room); + protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); [CanBeNull] @@ -39,14 +42,17 @@ namespace osu.Game.Screens.OnlinePlay private IBindable selectedItem { get; set; } private readonly FreeModSelectOverlay freeModSelectOverlay; + private readonly Room room; private WorkingBeatmap initialBeatmap; private RulesetInfo initialRuleset; private IReadOnlyList initialMods; private bool itemSelected; - protected OnlinePlaySongSelect() + protected OnlinePlaySongSelect(Room room) { + this.room = room; + Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; freeModSelectOverlay = new FreeModSelectOverlay @@ -59,6 +65,8 @@ namespace osu.Game.Screens.OnlinePlay [BackgroundDependencyLoader] private void load() { + LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; + initialBeatmap = Beatmap.Value; initialRuleset = Ruleset.Value; initialMods = Mods.Value.ToList(); @@ -72,8 +80,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 +116,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; @@ -144,7 +152,7 @@ namespace osu.Game.Screens.OnlinePlay return base.OnExiting(next); } - protected override ModSelectOverlay CreateModSelectOverlay() => new LocalPlayerModSelectOverlay + protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay { IsValidMod = IsValidMod }; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs index e1bd889088..3411c4afb1 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs @@ -23,41 +23,33 @@ namespace osu.Game.Screens.OnlinePlay RelativeSizeAxes = Axes.Both; } - public const float X_SHIFT = 200; - - public const double X_MOVE_DURATION = 800; - - public const double RESUME_TRANSITION_DELAY = DISAPPEAR_DURATION / 2; - public const double APPEAR_DURATION = 800; public const double DISAPPEAR_DURATION = 500; public override void OnEntering(IScreen last) { + base.OnEntering(last); this.FadeInFromZero(APPEAR_DURATION, Easing.OutQuint); - this.FadeInFromZero(APPEAR_DURATION, Easing.OutQuint); - this.MoveToX(X_SHIFT).MoveToX(0, X_MOVE_DURATION, Easing.OutQuint); } public override bool OnExiting(IScreen next) { + base.OnExiting(next); this.FadeOut(DISAPPEAR_DURATION, Easing.OutQuint); - this.MoveToX(X_SHIFT, X_MOVE_DURATION, Easing.OutQuint); - return false; } public override void OnResuming(IScreen last) { - this.Delay(RESUME_TRANSITION_DELAY).FadeIn(APPEAR_DURATION, Easing.OutQuint); - this.MoveToX(0, X_MOVE_DURATION, Easing.OutQuint); + base.OnResuming(last); + this.FadeIn(APPEAR_DURATION, Easing.OutQuint); } public override void OnSuspending(IScreen next) { + base.OnSuspending(next); this.FadeOut(DISAPPEAR_DURATION, Easing.OutQuint); - this.MoveToX(-X_SHIFT, X_MOVE_DURATION, Easing.OutQuint); } public override string ToString() => Title; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/CreatePlaylistsRoomButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/CreatePlaylistsRoomButton.cs index fcb773f8be..a9826a72eb 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/CreatePlaylistsRoomButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/CreatePlaylistsRoomButton.cs @@ -6,13 +6,11 @@ using osu.Game.Screens.OnlinePlay.Match.Components; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class CreatePlaylistsRoomButton : PurpleTriangleButton + public class CreatePlaylistsRoomButton : CreateRoomButton { [BackgroundDependencyLoader] private void load() { - Triangles.TriangleScale = 1.5f; - Text = "Create playlist"; } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs b/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs index 5b132c97fd..1edeef77df 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs @@ -1,62 +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.Logging; -using osu.Framework.Screens; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; -using osu.Game.Screens.OnlinePlay.Match; namespace osu.Game.Screens.OnlinePlay.Playlists { public class Playlists : OnlinePlayScreen { - protected override void UpdatePollingRate(bool isIdle) - { - var playlistsManager = (PlaylistsRoomManager)RoomManager; - - if (!this.IsCurrentScreen()) - { - playlistsManager.TimeBetweenListingPolls.Value = 0; - playlistsManager.TimeBetweenSelectionPolls.Value = 0; - } - else - { - switch (CurrentSubScreen) - { - case LoungeSubScreen _: - playlistsManager.TimeBetweenListingPolls.Value = isIdle ? 120000 : 15000; - playlistsManager.TimeBetweenSelectionPolls.Value = isIdle ? 120000 : 15000; - break; - - case RoomSubScreen _: - playlistsManager.TimeBetweenListingPolls.Value = 0; - playlistsManager.TimeBetweenSelectionPolls.Value = isIdle ? 30000 : 5000; - break; - - default: - playlistsManager.TimeBetweenListingPolls.Value = 0; - playlistsManager.TimeBetweenSelectionPolls.Value = 0; - break; - } - } - - Logger.Log($"Polling adjusted (listing: {playlistsManager.TimeBetweenListingPolls.Value}, selection: {playlistsManager.TimeBetweenSelectionPolls.Value})"); - } - - protected override Room CreateNewRoom() - { - return new Room { Name = { Value = $"{API.LocalUser}'s awesome playlist" } }; - } - protected override string ScreenTitle => "Playlists"; - protected override RoomManager CreateRoomManager() => new PlaylistsRoomManager(); - protected override LoungeSubScreen CreateLounge() => new PlaylistsLoungeSubScreen(); - - protected override OsuButton CreateNewMultiplayerGameButton() => new CreatePlaylistsRoomButton(); } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs index bfbff4240c..dced9b8691 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs @@ -1,7 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Rooms; +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; @@ -10,8 +18,62 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public class PlaylistsLoungeSubScreen : LoungeSubScreen { - protected override FilterControl CreateFilterControl() => new PlaylistsFilterControl(); + [Resolved] + private IAPIProvider api { get; set; } + + private Dropdown categoryDropdown; + + protected override IEnumerable CreateFilterControls() + { + categoryDropdown = new SlimEnumDropdown + { + RelativeSizeAxes = Axes.None, + Width = 160, + }; + + categoryDropdown.Current.BindValueChanged(_ => UpdateFilter()); + + return base.CreateFilterControls().Append(categoryDropdown); + } + + protected override FilterCriteria CreateFilterCriteria() + { + var criteria = base.CreateFilterCriteria(); + + switch (categoryDropdown.Current.Value) + { + case PlaylistsCategory.Normal: + criteria.Category = @"normal"; + break; + + case PlaylistsCategory.Spotlight: + criteria.Category = @"spotlight"; + break; + } + + return criteria; + } + + protected override OsuButton CreateNewRoomButton() => new CreatePlaylistsRoomButton(); + + protected override Room CreateNewRoom() + { + return new Room + { + Name = { Value = $"{api.LocalUser}'s awesome playlist" }, + Type = { Value = MatchType.Playlists } + }; + } protected override RoomSubScreen CreateRoomSubScreen(Room room) => new PlaylistsRoomSubScreen(room); + + protected override ListingPollingComponent CreatePollingComponent() => new ListingPollingComponent(); + + private enum PlaylistsCategory + { + Any, + Normal, + Spotlight + } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 567ea6b988..fa4a4d5112 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -13,6 +13,7 @@ using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; +using osu.Game.Users; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -20,8 +21,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public Action Exited; - public PlaylistsPlayer(PlaylistItem playlistItem, PlayerConfiguration configuration = null) - : base(playlistItem, configuration) + protected override UserActivity InitialActivity => new UserActivity.InPlaylistGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); + + public PlaylistsPlayer(Room room, PlaylistItem playlistItem, PlayerConfiguration configuration = null) + : base(room, playlistItem, configuration) { } @@ -51,8 +54,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override ResultsScreen CreateResults(ScoreInfo score) { - Debug.Assert(RoomId.Value != null); - return new PlaylistsResultsScreen(score, RoomId.Value.Value, PlaylistItem, true); + Debug.Assert(Room.RoomID.Value != null); + return new PlaylistsResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, true); } protected override async Task PrepareScoreForResultsAsync(Score score) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index 2b252f9db7..89bc659f63 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -32,6 +32,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private ScoreManager scoreManager { get; set; } + public PlaylistsResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem, bool allowRetry, bool allowWatchingReplay = true) : base(score, allowRetry, allowWatchingReplay) { @@ -166,23 +169,28 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// An optional pivot around which the scores were retrieved. private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) { - var scoreInfos = new List(scores.Select(s => s.CreateScoreInfo(playlistItem))); + var scoreInfos = scores.Select(s => s.CreateScoreInfo(playlistItem)).ToArray(); - // Select a score if we don't already have one selected. - // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). - if (SelectedScore.Value == null) + // Score panels calculate total score before displaying, which can take some time. In order to count that calculation as part of the loading spinner display duration, + // calculate the total scores locally before invoking the success callback. + scoreManager.OrderByTotalScoreAsync(scoreInfos).ContinueWith(_ => Schedule(() => { - Schedule(() => + // Select a score if we don't already have one selected. + // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). + if (SelectedScore.Value == null) { - // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.Id == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); - }); - } + Schedule(() => + { + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.Id == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + }); + } - // Invoke callback to add the scores. Exclude the user's current score which was added previously. - callback.Invoke(scoreInfos.Where(s => s.OnlineScoreID != Score?.OnlineScoreID)); + // Invoke callback to add the scores. Exclude the user's current score which was added previously. + callback.Invoke(scoreInfos.Where(s => s.OnlineScoreID != Score?.OnlineScoreID)); - hideLoadingSpinners(pivot); + hideLoadingSpinners(pivot); + })); } private void hideLoadingSpinners([CanBeNull] MultiplayerScores pivot = null) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs new file mode 100644 index 0000000000..3eb1cde0a4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public class PlaylistsRoomFooter : CompositeDrawable + { + public Action OnStart; + + public PlaylistsRoomFooter() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new[] + { + new PlaylistsReadyButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(600, 1), + Action = () => OnStart?.Invoke() + } + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomManager.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomManager.cs deleted file mode 100644 index c55d1c3e94..0000000000 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomManager.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Framework.Bindables; -using osu.Game.Screens.OnlinePlay.Components; - -namespace osu.Game.Screens.OnlinePlay.Playlists -{ - public class PlaylistsRoomManager : RoomManager - { - public readonly Bindable TimeBetweenListingPolls = new Bindable(); - public readonly Bindable TimeBetweenSelectionPolls = new Bindable(); - - protected override IEnumerable CreatePollingComponents() => new RoomPollingComponent[] - { - new ListingPollingComponent { TimeBetweenPolls = { BindTarget = TimeBetweenListingPolls } }, - new SelectionPollingComponent { TimeBetweenPolls = { BindTarget = TimeBetweenSelectionPolls } } - }; - } -} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs similarity index 93% rename from osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs rename to osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 5062a296a8..7384c60888 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Specialized; using Humanizer; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -22,21 +21,30 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsMatchSettingsOverlay : MatchSettingsOverlay + public class PlaylistsRoomSettingsOverlay : RoomSettingsOverlay { public Action EditPlaylist; - [BackgroundDependencyLoader] - private void load() + private MatchSettings settings; + + protected override OsuButton SubmitButton => settings.ApplyButton; + + protected override bool IsLoading => settings.IsLoading; // should probably be replaced with an OngoingOperationTracker. + + public PlaylistsRoomSettingsOverlay(Room room) + : base(room) { - Child = Settings = new MatchSettings - { - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Y, - EditPlaylist = () => EditPlaylist?.Invoke() - }; } + protected override void SelectBeatmap() => settings.SelectBeatmap(); + + protected override OnlinePlayComposite CreateSettings(Room room) => settings = new MatchSettings(room) + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Y, + EditPlaylist = () => EditPlaylist?.Invoke() + }; + protected class MatchSettings : OnlinePlayComposite { private const float disabled_alpha = 0.2f; @@ -48,17 +56,25 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public RoomAvailabilityPicker AvailabilityPicker; public TriangleButton ApplyButton; + public bool IsLoading => loadingLayer.State.Value == Visibility.Visible; + public OsuSpriteText ErrorText; private LoadingLayer loadingLayer; private DrawableRoomPlaylist playlist; private OsuSpriteText playlistLength; + private PurpleTriangleButton editPlaylistButton; + [Resolved(CanBeNull = true)] private IRoomManager manager { get; set; } - [Resolved] - private Bindable currentRoom { get; set; } + private readonly Room room; + + public MatchSettings(Room room) + { + this.room = room; + } [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -75,7 +91,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists RelativeSizeAxes = Axes.Both, RowDimensions = new[] { - new Dimension(GridSizeMode.Distributed), + new Dimension(), new Dimension(GridSizeMode.AutoSize), }, Content = new[] @@ -184,7 +200,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Child = new GridContainer { RelativeSizeAxes = Axes.X, - Height = 500, + Height = 448, Content = new[] { new Drawable[] @@ -202,7 +218,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, new Drawable[] { - new PurpleTriangleButton + editPlaylistButton = new PurpleTriangleButton { RelativeSizeAxes = Axes.X, Height = 40, @@ -295,6 +311,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists ApplyButton.Enabled.Value = hasValidSettings; } + public void SelectBeatmap() => editPlaylistButton.TriggerClick(); + private void onPlaylistChanged(object sender, NotifyCollectionChangedEventArgs e) => playlistLength.Text = $"Length: {Playlist.GetTotalDuration()}"; @@ -322,7 +340,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Duration.Value = DurationField.Current.Value; - manager?.CreateRoom(currentRoom.Value, onSuccess, onError); + manager?.CreateRoom(room, onSuccess, onError); loadingLayer.Show(); } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 092394446b..d5e423a438 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -3,11 +3,14 @@ using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; @@ -17,7 +20,6 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osuTK; -using Footer = osu.Game.Screens.OnlinePlay.Match.Components.Footer; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -27,251 +29,177 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public override string ShortTitle => "playlist"; - [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } + [Resolved] + private IAPIProvider api { get; set; } - [Resolved(typeof(Room), nameof(Room.Playlist))] - private BindableList playlist { get; set; } + private readonly IBindable isIdle = new BindableBool(); - private MatchSettingsOverlay settingsOverlay; private MatchLeaderboard leaderboard; - - private OverlinedHeader participantsHeader; - - private GridContainer mainContent; + private SelectionPollingComponent selectionPollingComponent; public PlaylistsRoomSubScreen(Room room) + : base(room, false) // Editing is temporarily not allowed. { Title = room.RoomID.Value == null ? "New playlist" : room.Name.Value; Activity.Value = new UserActivity.InLobby(room); } - [BackgroundDependencyLoader] - private void load() + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] IdleTracker idleTracker) { - AddRangeInternal(new Drawable[] - { - mainContent = new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Horizontal = 105, - Vertical = 20 - }, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - }, - Content = new[] - { - new Drawable[] { new Match.Components.Header() }, - new Drawable[] - { - participantsHeader = new OverlinedHeader("Participants") - { - ShowLine = false - } - }, - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 5 }, - Child = new ParticipantsDisplay(Direction.Horizontal) - { - Details = { BindTarget = participantsHeader.Details } - } - } - }, - new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedPlaylistHeader(), }, - new Drawable[] - { - new DrawableRoomPlaylistWithResults - { - RelativeSizeAxes = Axes.Both, - Items = { BindTarget = playlist }, - SelectedItem = { BindTarget = SelectedItem }, - RequestShowResults = item => - { - Debug.Assert(roomId.Value != null); - ParentScreen?.Push(new PlaylistsResultsScreen(null, roomId.Value.Value, item, false)); - } - } - }, - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - } - }, - null, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new[] - { - UserModsSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Bottom = 10 }, - Children = new Drawable[] - { - new OverlinedHeader("Extra mods"), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new PurpleTriangleButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } - } - } - }, - }, - new Drawable[] - { - new OverlinedHeader("Leaderboard") - }, - new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, - new Drawable[] { new OverlinedHeader("Chat"), }, - new Drawable[] { new MatchChatDisplay { RelativeSizeAxes = Axes.Both } } - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Relative, size: 0.4f, minSize: 120), - } - }, - null - }, - }, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 400), - new Dimension(), - new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 600), - new Dimension(), - } - } - } - }, - } - } - }, - new Drawable[] - { - new Footer { OnStart = StartPlay } - } - }, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - } - }, - settingsOverlay = new PlaylistsMatchSettingsOverlay - { - RelativeSizeAxes = Axes.Both, - EditPlaylist = () => this.Push(new PlaylistsSongSelect()), - State = { Value = roomId.Value == null ? Visibility.Visible : Visibility.Hidden } - } - }); + if (idleTracker != null) + isIdle.BindTo(idleTracker.IsIdle); - if (roomId.Value == null) - { - // A new room is being created. - // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. - mainContent.Hide(); - - settingsOverlay.State.BindValueChanged(visibility => - { - if (visibility.NewValue == Visibility.Hidden) - mainContent.Show(); - }, true); - } + AddInternal(selectionPollingComponent = new SelectionPollingComponent(Room)); } - [Resolved] - private IAPIProvider api { get; set; } - protected override void LoadComplete() { base.LoadComplete(); - roomId.BindValueChanged(id => + isIdle.BindValueChanged(_ => updatePollingRate(), true); + RoomId.BindValueChanged(id => { - if (id.NewValue == null) - settingsOverlay.Show(); - else + if (id.NewValue != null) { - settingsOverlay.Hide(); - // Set the first playlist item. // This is scheduled since updating the room and playlist may happen in an arbitrary order (via Room.CopyFrom()). - Schedule(() => SelectedItem.Value = playlist.FirstOrDefault()); + Schedule(() => SelectedItem.Value = Room.Playlist.FirstOrDefault()); } }, true); } - protected override Screen CreateGameplayScreen() => new PlayerLoader(() => new PlaylistsPlayer(SelectedItem.Value) + protected override Drawable CreateMainContent() => new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 5 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedPlaylistHeader(), }, + new Drawable[] + { + new DrawableRoomPlaylistWithResults + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = Room.Playlist }, + SelectedItem = { BindTarget = SelectedItem }, + RequestShowResults = item => + { + Debug.Assert(RoomId.Value != null); + ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item, false)); + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new[] + { + UserModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Alpha = 0, + Margin = new MarginPadding { Bottom = 10 }, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UserModSelectButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Text = "Select", + Action = ShowUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + }, + } + } + } + }, + }, + new Drawable[] + { + new OverlinedHeader("Leaderboard") + }, + new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, + new Drawable[] { new OverlinedHeader("Chat"), }, + new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Relative, size: 0.4f, minSize: 120), + } + }, + }, + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 400), + new Dimension(), + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 600), + } + }; + + protected override Drawable CreateFooter() => new PlaylistsRoomFooter + { + OnStart = StartPlay + }; + + protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new PlaylistsRoomSettingsOverlay(room) + { + EditPlaylist = () => + { + if (this.IsCurrentScreen()) + this.Push(new PlaylistsSongSelect(Room)); + }, + }; + + private void updatePollingRate() + { + selectionPollingComponent.TimeBetweenPolls.Value = isIdle.Value ? 30000 : 5000; + Logger.Log($"Polling adjusted (selection: {selectionPollingComponent.TimeBetweenPolls.Value})"); + } + + protected override Screen CreateGameplayScreen() => new PlayerLoader(() => new PlaylistsPlayer(Room, SelectedItem.Value) { Exited = () => leaderboard.RefreshScores() }); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 21335fc90c..03c95ec060 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -16,6 +16,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved] private BeatmapManager beatmaps { get; set; } + public PlaylistsSongSelect(Room room) + : base(room) + { + } + protected override BeatmapDetailArea CreateBeatmapDetailArea() => new MatchBeatmapDetailArea { CreateNewItem = createNewItem @@ -55,10 +60,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/OnlinePlay/RoomSubScreenComposite.cs b/osu.Game/Screens/OnlinePlay/RoomSubScreenComposite.cs deleted file mode 100644 index 4cfd881aa3..0000000000 --- a/osu.Game/Screens/OnlinePlay/RoomSubScreenComposite.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Match; - -namespace osu.Game.Screens.OnlinePlay -{ - /// - /// An with additional logic tracking the currently-selected inside a . - /// - public class RoomSubScreenComposite : OnlinePlayComposite - { - [Resolved] - private IBindable subScreenSelectedItem { get; set; } - - protected override void LoadComplete() - { - base.LoadComplete(); - - subScreenSelectedItem.BindValueChanged(_ => UpdateSelectedItem(), true); - } - - protected override void UpdateSelectedItem() - { - if (RoomID.Value == null) - { - // If the room hasn't been created yet, fall-back to the base logic. - base.UpdateSelectedItem(); - return; - } - - SelectedItem.Value = subScreenSelectedItem.Value; - } - } -} diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index aeb51813e4..ccc891d3bf 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -11,11 +11,11 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Game.Beatmaps; -using osu.Game.Rulesets; -using osu.Game.Screens.Menu; using osu.Game.Overlays; -using osu.Game.Users; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Menu; +using osu.Game.Users; namespace osu.Game.Screens { @@ -81,7 +81,10 @@ namespace osu.Game.Screens public virtual float BackgroundParallaxAmount => 1; - public virtual bool AllowRateAdjustments => true; + [Resolved] + private MusicController musicController { get; set; } + + public virtual bool? AllowTrackAdjustments => null; public Bindable Beatmap { get; private set; } @@ -91,6 +94,8 @@ namespace osu.Game.Screens private OsuScreenDependencies screenDependencies; + private bool? trackAdjustmentStateAtSuspend; + internal void CreateLeasedDependencies(IReadOnlyDependencyContainer dependencies) => createDependencies(dependencies); protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -170,8 +175,14 @@ namespace osu.Game.Screens { if (PlayResumeSound) sampleExit?.Play(); + applyArrivingDefaults(true); + // it's feasible to resume to a screen if the target screen never loaded successfully. + // in such a case there's no need to restore this value. + if (trackAdjustmentStateAtSuspend != null) + musicController.AllowTrackAdjustments = trackAdjustmentStateAtSuspend.Value; + base.OnResuming(last); } @@ -179,6 +190,8 @@ namespace osu.Game.Screens { base.OnSuspending(next); + trackAdjustmentStateAtSuspend = musicController.AllowTrackAdjustments; + onSuspendingLogo(); } @@ -186,17 +199,17 @@ namespace osu.Game.Screens { applyArrivingDefaults(false); - backgroundStack?.Push(ownedBackground = CreateBackground()); + if (AllowTrackAdjustments != null) + musicController.AllowTrackAdjustments = AllowTrackAdjustments.Value; - background = backgroundStack?.CurrentScreen as BackgroundScreen; - - if (background != ownedBackground) + if (backgroundStack?.Push(ownedBackground = CreateBackground()) != true) { - // background may have not been replaced, at which point we don't want to track the background lifetime. + // If the constructed instance was not actually pushed to the background stack, we don't want to track it unnecessarily. ownedBackground?.Dispose(); ownedBackground = null; } + background = backgroundStack?.CurrentScreen as BackgroundScreen; base.OnEntering(last); } @@ -242,7 +255,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/OsuScreenStack.cs b/osu.Game/Screens/OsuScreenStack.cs index e2a0414df7..ebbcbd7650 100644 --- a/osu.Game/Screens/OsuScreenStack.cs +++ b/osu.Game/Screens/OsuScreenStack.cs @@ -51,6 +51,6 @@ namespace osu.Game.Screens } private void setParallax(IScreen next) => - parallaxContainer.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * ((IOsuScreen)next)?.BackgroundParallaxAmount ?? 1.0f; + parallaxContainer.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * (((IOsuScreen)next)?.BackgroundParallaxAmount ?? 1.0f); } } diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs index fd1150650c..d77673580a 100644 --- a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -10,12 +10,12 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Ranking.Expanded; using osuTK; namespace osu.Game.Screens.Play @@ -172,7 +172,6 @@ namespace osu.Game.Screens.Play { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Top = 20 }, Current = mods }, diff --git a/osu.Game/Screens/Play/Break/BreakInfoLine.cs b/osu.Game/Screens/Play/Break/BreakInfoLine.cs index 18aab394f8..0bc79d6e77 100644 --- a/osu.Game/Screens/Play/Break/BreakInfoLine.cs +++ b/osu.Game/Screens/Play/Break/BreakInfoLine.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Utils; @@ -63,7 +64,7 @@ namespace osu.Game.Screens.Play.Break valueText.Text = newText; } - protected virtual string Format(T count) + protected virtual LocalisableString Format(T count) { if (count is Enum countEnum) return countEnum.GetDescription(); @@ -86,6 +87,6 @@ namespace osu.Game.Screens.Play.Break { } - protected override string Format(double count) => count.FormatAccuracy(); + protected override LocalisableString Format(double count) => count.FormatAccuracy(); } } 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/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index 71bea2a145..ea158c5789 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -10,7 +10,9 @@ using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Utils; +using osu.Game.Audio.Effects; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Drawables; using osuTK; @@ -20,9 +22,9 @@ namespace osu.Game.Screens.Play { /// /// Manage the animation to be applied when a player fails. - /// Single file; automatically disposed after use. + /// Single use and automatically disposed after use. /// - public class FailAnimation : Component + public class FailAnimation : CompositeDrawable { public Action OnComplete; @@ -32,6 +34,8 @@ namespace osu.Game.Screens.Play private Track track; + private AudioFilter failLowPassFilter; + private const float duration = 2500; private Sample failSample; @@ -46,6 +50,8 @@ namespace osu.Game.Screens.Play { track = beatmap.Value.Track; failSample = audio.Samples.Get(@"Gameplay/failsound"); + + AddInternal(failLowPassFilter = new AudioFilter(audio.TrackMixer)); } private bool started; @@ -68,6 +74,8 @@ namespace osu.Game.Screens.Play Expire(); }); + failLowPassFilter.CutoffTo(300, duration, Easing.OutCubic); + track.AddAdjustment(AdjustableProperty.Frequency, trackFreq); applyToPlayfield(drawableRuleset.Playfield); diff --git a/osu.Game/Screens/Play/GameplayBeatmap.cs b/osu.Game/Screens/Play/GameplayBeatmap.cs deleted file mode 100644 index 74fbe540fa..0000000000 --- a/osu.Game/Screens/Play/GameplayBeatmap.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Beatmaps.Timing; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; - -namespace osu.Game.Screens.Play -{ - public class GameplayBeatmap : Component, IBeatmap - { - public readonly IBeatmap PlayableBeatmap; - - public GameplayBeatmap(IBeatmap playableBeatmap) - { - PlayableBeatmap = playableBeatmap; - } - - public BeatmapInfo BeatmapInfo - { - get => PlayableBeatmap.BeatmapInfo; - set => PlayableBeatmap.BeatmapInfo = value; - } - - public BeatmapMetadata Metadata => PlayableBeatmap.Metadata; - - public ControlPointInfo ControlPointInfo - { - get => PlayableBeatmap.ControlPointInfo; - set => PlayableBeatmap.ControlPointInfo = value; - } - - public List Breaks => PlayableBeatmap.Breaks; - - public double TotalBreakTime => PlayableBeatmap.TotalBreakTime; - - public IReadOnlyList HitObjects => PlayableBeatmap.HitObjects; - - public IEnumerable GetStatistics() => PlayableBeatmap.GetStatistics(); - - public double GetMostCommonBeatLength() => PlayableBeatmap.GetMostCommonBeatLength(); - - public IBeatmap Clone() => PlayableBeatmap.Clone(); - - private readonly Bindable lastJudgementResult = new Bindable(); - - public IBindable LastJudgementResult => lastJudgementResult; - - public void ApplyResult(JudgementResult result) => lastJudgementResult.Value = result; - } -} diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index f791da80c8..0c9b827a41 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -13,6 +13,7 @@ namespace osu.Game.Screens.Play /// /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. /// + [Cached] public abstract class GameplayClockContainer : Container, IAdjustableClock { /// @@ -35,6 +36,11 @@ namespace osu.Game.Screens.Play /// protected IClock SourceClock { get; private set; } + /// + /// Invoked when a seek has been performed via + /// + public event Action OnSeek; + /// /// Creates a new . /// @@ -88,6 +94,8 @@ namespace osu.Game.Screens.Play // Manually process to make sure the gameplay clock is correctly updated after a seek. GameplayClock.UnderlyingClock.ProcessFrame(); + + OnSeek?.Invoke(); } /// diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 4a28da0dde..9e3400b09d 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 { @@ -41,18 +42,18 @@ namespace osu.Game.Screens.Play /// /// Action that is invoked when is triggered. /// - protected virtual Action BackAction => () => InternalButtons.Children.LastOrDefault()?.Click(); + protected virtual Action BackAction => () => InternalButtons.Children.LastOrDefault()?.TriggerClick(); /// /// 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?.TriggerClick(); public abstract string Header { get; } public abstract string Description { get; } - protected ButtonContainer InternalButtons; + protected SelectionCycleFillFlowContainer InternalButtons; public IReadOnlyList Buttons => InternalButtons; private FillFlowContainer retryCounterContainer; @@ -60,8 +61,6 @@ namespace osu.Game.Screens.Play protected GameplayMenuOverlay() { RelativeSizeAxes = Axes.Both; - - State.ValueChanged += s => InternalButtons.Deselect(); } [BackgroundDependencyLoader] @@ -116,7 +115,7 @@ namespace osu.Game.Screens.Play } } }, - InternalButtons = new ButtonContainer + InternalButtons = new SelectionCycleFillFlowContainer { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, @@ -141,6 +140,8 @@ namespace osu.Game.Screens.Play }, }; + State.ValueChanged += s => InternalButtons.Deselect(); + updateRetryCount(); } @@ -183,14 +184,12 @@ namespace osu.Game.Screens.Play } }; - button.Selected.ValueChanged += selected => buttonSelectionChanged(button, selected.NewValue); - InternalButtons.Add(button); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.SelectPrevious: InternalButtons.SelectPrevious(); @@ -212,18 +211,10 @@ namespace osu.Game.Screens.Play return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } - 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/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs new file mode 100644 index 0000000000..44f72022f7 --- /dev/null +++ b/osu.Game/Screens/Play/GameplayState.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 System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; + +#nullable enable + +namespace osu.Game.Screens.Play +{ + /// + /// The state of an active gameplay session, generally constructed and exposed by . + /// + public class GameplayState + { + /// + /// The final post-convert post-mod-application beatmap. + /// + public readonly IBeatmap Beatmap; + + /// + /// The ruleset used in gameplay. + /// + public readonly Ruleset Ruleset; + + /// + /// The mods applied to the gameplay. + /// + public readonly IReadOnlyList Mods; + + /// + /// The gameplay score. + /// + public readonly Score Score; + + /// + /// A bindable tracking the last judgement result applied to any hit object. + /// + public IBindable LastJudgementResult => lastJudgementResult; + + private readonly Bindable lastJudgementResult = new Bindable(); + + public GameplayState(IBeatmap beatmap, Ruleset ruleset, IReadOnlyList? mods = null, Score? score = null) + { + Beatmap = beatmap; + Ruleset = ruleset; + Score = score ?? new Score(); + Mods = mods ?? ArraySegment.Empty; + } + + /// + /// Applies the score change of a to this . + /// + /// The to apply. + public void ApplyResult(JudgementResult result) => lastJudgementResult.Value = result; + } +} diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 718ae24cf1..6d87211ddc 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -31,7 +32,7 @@ namespace osu.Game.Screens.Play.HUD Current.BindTo(scoreProcessor.Combo); } - protected override string FormatCount(int count) + protected override LocalisableString FormatCount(int count) { return $@"{count}x"; } diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 63de5c8de5..87b19e8433 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -11,7 +11,6 @@ namespace osu.Game.Screens.Play.HUD public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableDrawable { public DefaultScoreCounter() - : base(6) { Anchor = Anchor.TopCentre; Origin = Anchor.TopCentre; diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs index 34efeab54c..871555e5a3 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs @@ -6,29 +6,58 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Caching; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; using osu.Game.Users; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { - public class GameplayLeaderboard : FillFlowContainer + public class GameplayLeaderboard : CompositeDrawable { + private readonly int maxPanels; private readonly Cached sorting = new Cached(); public Bindable Expanded = new Bindable(); - public GameplayLeaderboard() + protected readonly FillFlowContainer Flow; + + private bool requiresScroll; + private readonly OsuScrollContainer scroll; + + private GameplayLeaderboardScore trackedScore; + + /// + /// Create a new leaderboard. + /// + /// The maximum panels to show at once. Defines the maximum height of this component. + public GameplayLeaderboard(int maxPanels = 8) { + this.maxPanels = maxPanels; + Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH; - Direction = FillDirection.Vertical; - - Spacing = new Vector2(2.5f); - - LayoutDuration = 250; - LayoutEasing = Easing.OutQuint; + InternalChildren = new Drawable[] + { + scroll = new InputDisabledScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = Flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + X = GameplayLeaderboardScore.SHEAR_WIDTH, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2.5f), + LayoutDuration = 450, + LayoutEasing = Easing.OutQuint, + } + } + }; } protected override void LoadComplete() @@ -46,24 +75,87 @@ namespace osu.Game.Screens.Play.HUD /// Whether the player should be tracked on the leaderboard. /// Set to true for the local player or a player whose replay is currently being played. /// - public ILeaderboardScore AddPlayer([CanBeNull] User user, bool isTracked) + public ILeaderboardScore Add([CanBeNull] User user, bool isTracked) { - var drawable = new GameplayLeaderboardScore(user, isTracked) - { - Expanded = { BindTarget = Expanded }, - }; + var drawable = CreateLeaderboardScoreDrawable(user, isTracked); - base.Add(drawable); + if (isTracked) + { + if (trackedScore != null) + throw new InvalidOperationException("Cannot track more than one score."); + + trackedScore = drawable; + } + + drawable.Expanded.BindTo(Expanded); + + Flow.Add(drawable); drawable.TotalScore.BindValueChanged(_ => sorting.Invalidate(), true); - Height = Count * (GameplayLeaderboardScore.PANEL_HEIGHT + Spacing.Y); + int displayCount = Math.Min(Flow.Count, maxPanels); + Height = displayCount * (GameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y); + requiresScroll = displayCount != Flow.Count; return drawable; } - public sealed override void Add(GameplayLeaderboardScore drawable) + public void Clear() { - throw new NotSupportedException($"Use {nameof(AddPlayer)} instead."); + Flow.Clear(); + trackedScore = null; + scroll.ScrollToStart(false); + } + + protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(User user, bool isTracked) => + new GameplayLeaderboardScore(user, isTracked); + + protected override void Update() + { + base.Update(); + + if (requiresScroll && trackedScore != null) + { + float scrollTarget = scroll.GetChildPosInContent(trackedScore) + trackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; + scroll.ScrollTo(scrollTarget, false); + } + + const float panel_height = GameplayLeaderboardScore.PANEL_HEIGHT; + + float fadeBottom = scroll.Current + scroll.DrawHeight; + float fadeTop = scroll.Current + panel_height; + + if (scroll.Current <= 0) fadeTop -= panel_height; + if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height; + + // logic is mostly shared with Leaderboard, copied here for simplicity. + foreach (var c in Flow.Children) + { + float topY = c.ToSpaceOfOtherDrawable(Vector2.Zero, Flow).Y; + float bottomY = topY + panel_height; + + bool requireTopFade = requiresScroll && topY <= fadeTop; + bool requireBottomFade = requiresScroll && bottomY >= fadeBottom; + + if (!requireTopFade && !requireBottomFade) + c.Colour = Color4.White; + else if (topY > fadeBottom + panel_height || bottomY < fadeTop - panel_height) + c.Colour = Color4.Transparent; + else + { + if (requireBottomFade) + { + c.Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(Math.Min(1 - (topY - fadeBottom) / panel_height, 1)), + Color4.White.Opacity(Math.Min(1 - (bottomY - fadeBottom) / panel_height, 1))); + } + else if (requiresScroll) + { + c.Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(Math.Min(1 - (fadeTop - topY) / panel_height, 1)), + Color4.White.Opacity(Math.Min(1 - (fadeTop - bottomY) / panel_height, 1))); + } + } + } } private void sort() @@ -71,15 +163,26 @@ namespace osu.Game.Screens.Play.HUD if (sorting.IsValid) return; - var orderedByScore = this.OrderByDescending(i => i.TotalScore.Value).ToList(); + var orderedByScore = Flow.OrderByDescending(i => i.TotalScore.Value).ToList(); - for (int i = 0; i < Count; i++) + for (int i = 0; i < Flow.Count; i++) { - SetLayoutPosition(orderedByScore[i], i); + Flow.SetLayoutPosition(orderedByScore[i], i); orderedByScore[i].ScorePosition = i + 1; } sorting.Validate(); } + + private class InputDisabledScrollContainer : OsuScrollContainer + { + public InputDisabledScrollContainer() + { + ScrollbarVisible = false; + } + + public override bool HandlePositionalInput => false; + public override bool HandleNonPositionalInput => false; + } } } diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index 10476e5565..85cf9d1966 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -54,6 +54,10 @@ namespace osu.Game.Screens.Play.HUD public BindableInt Combo { get; } = new BindableInt(); public BindableBool HasQuit { get; } = new BindableBool(); + public Color4? BackgroundColour { get; set; } + + public Color4? TextColour { get; set; } + private int? scorePosition; public int? ScorePosition @@ -77,7 +81,10 @@ namespace osu.Game.Screens.Play.HUD [CanBeNull] public User User { get; } - private readonly bool trackedPlayer; + /// + /// Whether this score is the local user or a replay player (and should be focused / always visible). + /// + public readonly bool Tracked; private Container mainFillContainer; @@ -93,11 +100,11 @@ namespace osu.Game.Screens.Play.HUD /// Creates a new . /// /// The score's player. - /// Whether the player is the local user or a replay player. - public GameplayLeaderboardScore([CanBeNull] User user, bool trackedPlayer) + /// Whether the player is the local user or a replay player. + public GameplayLeaderboardScore([CanBeNull] User user, bool tracked) { User = user; - this.trackedPlayer = trackedPlayer; + Tracked = tracked; AutoSizeAxes = Axes.X; Height = PANEL_HEIGHT; @@ -331,19 +338,19 @@ namespace osu.Game.Screens.Play.HUD if (scorePosition == 1) { widthExtension = true; - panelColour = Color4Extensions.FromHex("7fcc33"); - textColour = Color4.White; + panelColour = BackgroundColour ?? Color4Extensions.FromHex("7fcc33"); + textColour = TextColour ?? Color4.White; } - else if (trackedPlayer) + else if (Tracked) { widthExtension = true; - panelColour = Color4Extensions.FromHex("ffd966"); - textColour = Color4Extensions.FromHex("2e576b"); + panelColour = BackgroundColour ?? Color4Extensions.FromHex("ffd966"); + textColour = TextColour ?? Color4Extensions.FromHex("2e576b"); } else { - panelColour = Color4Extensions.FromHex("3399cc"); - textColour = Color4.White; + panelColour = BackgroundColour ?? Color4Extensions.FromHex("3399cc"); + textColour = TextColour ?? Color4.White; } this.TransformTo(nameof(SizeContainerLeftPadding), widthExtension ? -top_player_left_width_extension : 0, panel_transition_duration, Easing.OutElastic); diff --git a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs index e09630d2c4..e05eff5f3e 100644 --- a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs @@ -14,8 +14,8 @@ namespace osu.Game.Screens.Play.HUD { private Bindable scoreDisplayMode; - protected GameplayScoreCounter(int leading = 0, bool useCommaSeparator = false) - : base(leading, useCommaSeparator) + protected GameplayScoreCounter() + : base(6) { } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 89f61785e8..fb49dedce7 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -33,6 +33,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private const float chevron_size = 8; private SpriteIcon arrow; + private SpriteIcon iconEarly; + private SpriteIcon iconLate; private Container colourBarsEarly; private Container colourBarsLate; @@ -97,25 +99,21 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters RelativeSizeAxes = Axes.Both, Height = 0.5f, }, - new SpriteIcon + iconEarly = new SpriteIcon { Y = -10, Size = new Vector2(10), Icon = FontAwesome.Solid.ShippingFast, Anchor = Anchor.TopCentre, Origin = Anchor.Centre, - // undo any layout rotation to display the icon the correct orientation - Rotation = -Rotation, }, - new SpriteIcon + iconLate = new SpriteIcon { Y = 10, Size = new Vector2(10), Icon = FontAwesome.Solid.Bicycle, Anchor = Anchor.BottomCentre, Origin = Anchor.Centre, - // undo any layout rotation to display the icon the correct orientation - Rotation = -Rotation, } } }, @@ -143,6 +141,15 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters arrow.Delay(200).FadeInFromZero(600); } + protected override void Update() + { + base.Update(); + + // undo any layout rotation to display icons in the correct orientation + iconEarly.Rotation = -Rotation; + iconLate.Rotation = -Rotation; + } + private void createColourBars(OsuColour colours) { var windows = HitWindows.GetAllAvailableWindows().ToArray(); @@ -279,5 +286,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters this.FadeTo(0.8f, 150).Then().FadeOut(judgement_fade_duration).Expire(); } } + + public override void Clear() => judgementsContainer.Clear(); } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs index dda2a6da95..5012be7249 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -33,6 +33,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters judgementsFlow.Push(GetColourForHitResult(judgement.Type)); } + public override void Clear() => judgementsFlow.Clear(); + private class JudgementFlow : FillFlowContainer { private const int max_available_judgements = 20; diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index 788ba5be8c..c7b06a3a2c 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -22,6 +22,9 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters [Resolved] private OsuColour colours { get; set; } + [Resolved(canBeNull: true)] + private GameplayClockContainer gameplayClockContainer { get; set; } + public bool UsesFixedAnchor { get; set; } [BackgroundDependencyLoader(true)] @@ -34,6 +37,9 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { base.LoadComplete(); + if (gameplayClockContainer != null) + gameplayClockContainer.OnSeek += Clear; + processor.NewJudgement += OnNewJudgement; } @@ -67,12 +73,21 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } + /// + /// Invoked by . + /// Any inheritors of should have this method clear their container that displays the hit error results. + /// + public abstract void Clear(); + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (processor != null) processor.NewJudgement -= OnNewJudgement; + + if (gameplayClockContainer != null) + gameplayClockContainer.OnSeek -= Clear; } } } diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 284ac899ed..850543136c 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -206,9 +206,9 @@ namespace osu.Game.Screens.Play.HUD base.OnHoverLost(e); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Back: case GlobalAction.PauseGameplay: // in the future this behaviour will differ for replays etc. @@ -220,9 +220,9 @@ namespace osu.Game.Screens.Play.HUD return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Back: case GlobalAction.PauseGameplay: diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index acff949353..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; /// @@ -170,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) @@ -186,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) @@ -261,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/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs new file mode 100644 index 0000000000..b1c07512dd --- /dev/null +++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs @@ -0,0 +1,176 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public class MatchScoreDisplay : CompositeDrawable + { + private const float bar_height = 18; + private const float font_size = 50; + + public BindableInt Team1Score = new BindableInt(); + public BindableInt Team2Score = new BindableInt(); + + protected MatchScoreCounter Score1Text; + protected MatchScoreCounter Score2Text; + + private Drawable score1Bar; + private Drawable score2Bar; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + new Box + { + Name = "top bar red (static)", + RelativeSizeAxes = Axes.X, + Height = bar_height / 4, + Width = 0.5f, + Colour = colours.TeamColourRed, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight + }, + new Box + { + Name = "top bar blue (static)", + RelativeSizeAxes = Axes.X, + Height = bar_height / 4, + Width = 0.5f, + Colour = colours.TeamColourBlue, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft + }, + score1Bar = new Box + { + Name = "top bar red", + RelativeSizeAxes = Axes.X, + Height = bar_height, + Width = 0, + Colour = colours.TeamColourRed, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight + }, + score2Bar = new Box + { + Name = "top bar blue", + RelativeSizeAxes = Axes.X, + Height = bar_height, + Width = 0, + Colour = colours.TeamColourBlue, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = font_size + bar_height, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new Drawable[] + { + Score1Text = new MatchScoreCounter + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }, + Score2Text = new MatchScoreCounter + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Team1Score.BindValueChanged(_ => updateScores()); + Team2Score.BindValueChanged(_ => updateScores(), true); + } + + private void updateScores() + { + Score1Text.Current.Value = Team1Score.Value; + Score2Text.Current.Value = Team2Score.Value; + + int comparison = Team1Score.Value.CompareTo(Team2Score.Value); + + if (comparison > 0) + { + Score1Text.Winning = true; + Score2Text.Winning = false; + } + else if (comparison < 0) + { + Score1Text.Winning = false; + Score2Text.Winning = true; + } + else + { + Score1Text.Winning = false; + Score2Text.Winning = false; + } + + var winningBar = Team1Score.Value > Team2Score.Value ? score1Bar : score2Bar; + var losingBar = Team1Score.Value <= Team2Score.Value ? score1Bar : score2Bar; + + var diff = Math.Max(Team1Score.Value, Team2Score.Value) - Math.Min(Team1Score.Value, Team2Score.Value); + + losingBar.ResizeWidthTo(0, 400, Easing.OutQuint); + winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + Score1Text.X = -Math.Max(5 + Score1Text.DrawWidth / 2, score1Bar.DrawWidth); + Score2Text.X = Math.Max(5 + Score2Text.DrawWidth / 2, score2Bar.DrawWidth); + } + + protected class MatchScoreCounter : CommaSeparatedScoreCounter + { + private OsuSpriteText displayedSpriteText; + + public MatchScoreCounter() + { + Margin = new MarginPadding { Top = bar_height, Horizontal = 10 }; + } + + public bool Winning + { + set => updateFont(value); + } + + protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => + { + displayedSpriteText = s; + displayedSpriteText.Spacing = new Vector2(-6); + updateFont(false); + }); + + private void updateFont(bool winning) + => displayedSpriteText.Font = winning + ? OsuFont.Torus.With(weight: FontWeight.Bold, size: font_size, fixedWidth: true) + : OsuFont.Torus.With(weight: FontWeight.Regular, size: font_size * 0.8f, fixedWidth: true); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index 2f7ca74372..b4a3eb209a 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -15,24 +15,26 @@ using osuTK; namespace osu.Game.Screens.Play.HUD { - public class ModDisplay : Container, IHasCurrentValue> + /// + /// Displays a single-line horizontal auto-sized flow of mods. For cases where wrapping is required, use instead. + /// + public class ModDisplay : CompositeDrawable, IHasCurrentValue> { private const int fade_duration = 1000; public ExpansionMode ExpansionMode = ExpansionMode.ExpandOnHover; - private readonly Bindable> current = new Bindable>(); + private readonly BindableWithCurrent> current = new BindableWithCurrent>(); public Bindable> Current { - get => current; + get => current.Current; set { if (value == null) throw new ArgumentNullException(nameof(value)); - current.UnbindBindings(); - current.BindTo(value); + current.Current = value; } } @@ -42,51 +44,34 @@ namespace osu.Game.Screens.Play.HUD { AutoSizeAxes = Axes.Both; - Child = new FillFlowContainer + InternalChild = iconsContainer = new ReverseChildIDFillFlowContainer { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - iconsContainer = new ReverseChildIDFillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - }, - }, + Direction = FillDirection.Horizontal, }; } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - Current.UnbindAll(); - } - protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(mods => - { - iconsContainer.Clear(); - - if (mods.NewValue != null) - { - foreach (Mod mod in mods.NewValue) - iconsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.6f) }); - - appearTransform(); - } - }, true); + Current.BindValueChanged(updateDisplay, true); iconsContainer.FadeInFromZero(fade_duration, Easing.OutQuint); } + private void updateDisplay(ValueChangedEvent> mods) + { + iconsContainer.Clear(); + + if (mods.NewValue == null) return; + + foreach (Mod mod in mods.NewValue) + iconsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.6f) }); + + appearTransform(); + } + private void appearTransform() { expand(); diff --git a/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs b/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs new file mode 100644 index 0000000000..ff3ca6460f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// A horizontally wrapping display of mods. For cases where wrapping is not required, use instead. + /// + public class ModFlowDisplay : ReverseChildIDFillFlowContainer, IHasCurrentValue> + { + private const int fade_duration = 1000; + + private readonly BindableWithCurrent> current = new BindableWithCurrent>(); + + public Bindable> Current + { + get => current.Current; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + current.Current = value; + } + } + + private float iconScale = 1; + + public float IconScale + { + get => iconScale; + set + { + iconScale = value; + updateDisplay(); + } + } + + public ModFlowDisplay() + { + Direction = FillDirection.Full; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateDisplay(), true); + + this.FadeInFromZero(fade_duration, Easing.OutQuint); + } + + private void updateDisplay() + { + Clear(); + + if (current.Value == null) return; + + Spacing = new Vector2(0, -12 * iconScale); + + foreach (Mod mod in current.Value) + { + Add(new ModIcon(mod) + { + Scale = new Vector2(0.6f * iconScale), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index c3bfe19b29..19cb6aeb50 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -7,12 +7,17 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Graphics; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Scoring; +using osu.Game.Users; +using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { @@ -21,6 +26,11 @@ namespace osu.Game.Screens.Play.HUD { protected readonly Dictionary UserScores = new Dictionary(); + public readonly SortedDictionary TeamScores = new SortedDictionary(); + + [Resolved] + private OsuColour colours { get; set; } + [Resolved] private SpectatorClient spectatorClient { get; set; } @@ -31,21 +41,24 @@ namespace osu.Game.Screens.Play.HUD private UserLookupCache userLookupCache { get; set; } private readonly ScoreProcessor scoreProcessor; - private readonly BindableList playingUsers; + private readonly MultiplayerRoomUser[] playingUsers; private Bindable scoringMode; + private readonly IBindableList playingUserIds = new BindableList(); + + private bool hasTeams => TeamScores.Count > 0; + /// /// Construct a new leaderboard. /// /// A score processor instance to handle score calculation for scores of users in the match. - /// IDs of all users in this match. - public MultiplayerGameplayLeaderboard(ScoreProcessor scoreProcessor, int[] userIds) + /// IDs of all users in this match. + public MultiplayerGameplayLeaderboard(ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) { // todo: this will eventually need to be created per user to support different mod combinations. this.scoreProcessor = scoreProcessor; - // todo: this will likely be passed in as User instances. - playingUsers = new BindableList(userIds); + playingUsers = users; } [BackgroundDependencyLoader] @@ -53,22 +66,32 @@ namespace osu.Game.Screens.Play.HUD { scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); - foreach (var userId in playingUsers) + foreach (var user in playingUsers) { - // probably won't be required in the final implementation. - var resolvedUser = userLookupCache.GetUserAsync(userId).Result; - - var trackedUser = CreateUserData(userId, scoreProcessor); + var trackedUser = CreateUserData(user, scoreProcessor); trackedUser.ScoringMode.BindTo(scoringMode); + UserScores[user.UserID] = trackedUser; - var leaderboardScore = AddPlayer(resolvedUser, resolvedUser?.Id == api.LocalUser.Value.Id); - leaderboardScore.Accuracy.BindTo(trackedUser.Accuracy); - leaderboardScore.TotalScore.BindTo(trackedUser.Score); - leaderboardScore.Combo.BindTo(trackedUser.CurrentCombo); - leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit); - - UserScores[userId] = trackedUser; + if (trackedUser.Team is int team && !TeamScores.ContainsKey(team)) + TeamScores.Add(team, new BindableInt()); } + + userLookupCache.GetUsersAsync(playingUsers.Select(u => u.UserID).ToArray()).ContinueWith(users => Schedule(() => + { + foreach (var user in users.Result) + { + if (user == null) + continue; + + var trackedUser = UserScores[user.Id]; + + var leaderboardScore = Add(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() @@ -76,21 +99,50 @@ namespace osu.Game.Screens.Play.HUD base.LoadComplete(); // BindableList handles binding in a really bad way (Clear then AddRange) so we need to do this manually.. - foreach (int userId in playingUsers) + foreach (var user in playingUsers) { - spectatorClient.WatchUser(userId); + spectatorClient.WatchUser(user.UserID); - if (!multiplayerClient.CurrentMatchPlayingUserIds.Contains(userId)) - usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { userId })); + if (!multiplayerClient.CurrentMatchPlayingUserIds.Contains(user.UserID)) + usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { user.UserID })); } - playingUsers.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); - playingUsers.BindCollectionChanged(usersChanged); + // bind here is to support players leaving the match. + // new players are not supported. + playingUserIds.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); + playingUserIds.BindCollectionChanged(usersChanged); // this leaderboard should be guaranteed to be completely loaded before the gameplay starts (is a prerequisite in MultiplayerPlayer). spectatorClient.OnNewFrames += handleIncomingFrames; } + protected virtual TrackedUserData CreateUserData(MultiplayerRoomUser user, ScoreProcessor scoreProcessor) => new TrackedUserData(user, scoreProcessor); + + protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(User user, bool isTracked) + { + var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked); + + if (UserScores[user.Id].Team is int team) + { + leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f); + leaderboardScore.TextColour = Color4.White; + } + + return leaderboardScore; + } + + private Color4 getTeamColour(int team) + { + switch (team) + { + case 0: + return colours.TeamColourRed; + + default: + return colours.TeamColourBlue; + } + } + private void usersChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) @@ -115,9 +167,26 @@ namespace osu.Game.Screens.Play.HUD trackedData.Frames.Add(new TimedFrame(bundle.Frames.First().Time, bundle.Header)); trackedData.UpdateScore(); + + updateTotals(); }); - protected virtual TrackedUserData CreateUserData(int userId, ScoreProcessor scoreProcessor) => new TrackedUserData(userId, scoreProcessor); + private void updateTotals() + { + if (!hasTeams) + return; + + foreach (var scores in TeamScores.Values) scores.Value = 0; + + foreach (var u in UserScores.Values) + { + if (u.Team == null) + continue; + + if (TeamScores.TryGetValue(u.Team.Value, out var team)) + team.Value += (int)Math.Round(u.Score.Value); + } + } protected override void Dispose(bool isDisposing) { @@ -127,7 +196,7 @@ namespace osu.Game.Screens.Play.HUD { foreach (var user in playingUsers) { - spectatorClient.StopWatchingUser(user); + spectatorClient.StopWatchingUser(user.UserID); } spectatorClient.OnNewFrames -= handleIncomingFrames; @@ -136,7 +205,7 @@ namespace osu.Game.Screens.Play.HUD protected class TrackedUserData { - public readonly int UserId; + public readonly MultiplayerRoomUser User; public readonly ScoreProcessor ScoreProcessor; public readonly BindableDouble Score = new BindableDouble(); @@ -148,9 +217,11 @@ namespace osu.Game.Screens.Play.HUD public readonly List Frames = new List(); - public TrackedUserData(int userId, ScoreProcessor scoreProcessor) + public int? Team => (User.MatchState as TeamVersusUserState)?.TeamID; + + public TrackedUserData(MultiplayerRoomUser user, ScoreProcessor scoreProcessor) { - UserId = userId; + User = user; ScoreProcessor = scoreProcessor; ScoringMode.BindValueChanged(_ => UpdateScore()); diff --git a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs new file mode 100644 index 0000000000..ef289c2a20 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs @@ -0,0 +1,227 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public class PerformancePointsCounter : RollingCounter, ISkinnableDrawable + { + public bool UsesFixedAnchor { get; set; } + + protected override bool IsRollingProportional => true; + + protected override double RollingDuration => 1000; + + private const float alpha_when_invalid = 0.3f; + + [CanBeNull] + [Resolved(CanBeNull = true)] + private ScoreProcessor scoreProcessor { get; set; } + + [Resolved(CanBeNull = true)] + [CanBeNull] + private GameplayState gameplayState { get; set; } + + [CanBeNull] + private List timedAttributes; + + private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); + + private JudgementResult lastJudgement; + + public PerformancePointsCounter() + { + Current.Value = DisplayedCount = 0; + } + + private Mod[] clonedMods; + + [BackgroundDependencyLoader] + private void load(OsuColour colours, BeatmapDifficultyCache difficultyCache) + { + Colour = colours.BlueLighter; + + if (gameplayState != null) + { + clonedMods = gameplayState.Mods.Select(m => m.DeepClone()).ToArray(); + + var gameplayWorkingBeatmap = new GameplayWorkingBeatmap(gameplayState.Beatmap); + difficultyCache.GetTimedDifficultyAttributesAsync(gameplayWorkingBeatmap, gameplayState.Ruleset, clonedMods, loadCancellationSource.Token) + .ContinueWith(r => Schedule(() => + { + timedAttributes = r.Result; + IsValid = true; + if (lastJudgement != null) + onJudgementChanged(lastJudgement); + }), TaskContinuationOptions.OnlyOnRanToCompletion); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (scoreProcessor != null) + { + scoreProcessor.NewJudgement += onJudgementChanged; + scoreProcessor.JudgementReverted += onJudgementChanged; + } + } + + private bool isValid; + + protected bool IsValid + { + set + { + if (value == isValid) + return; + + isValid = value; + DrawableCount.FadeTo(isValid ? 1 : alpha_when_invalid, 1000, Easing.OutQuint); + } + } + + private void onJudgementChanged(JudgementResult judgement) + { + lastJudgement = judgement; + + var attrib = getAttributeAtTime(judgement); + + if (gameplayState == null || attrib == null) + { + IsValid = false; + return; + } + + // awkward but we need to make sure the true mods are not passed to PerformanceCalculator as it makes a mess of track applications. + var scoreInfo = gameplayState.Score.ScoreInfo.DeepClone(); + scoreInfo.Mods = clonedMods; + + var calculator = gameplayState.Ruleset.CreatePerformanceCalculator(attrib, scoreInfo); + + Current.Value = (int)Math.Round(calculator?.Calculate() ?? 0, MidpointRounding.AwayFromZero); + IsValid = true; + } + + [CanBeNull] + private DifficultyAttributes getAttributeAtTime(JudgementResult judgement) + { + if (timedAttributes == null || timedAttributes.Count == 0) + return null; + + int attribIndex = timedAttributes.BinarySearch(new TimedDifficultyAttributes(judgement.HitObject.GetEndTime(), null)); + if (attribIndex < 0) + attribIndex = ~attribIndex - 1; + + return timedAttributes[Math.Clamp(attribIndex, 0, timedAttributes.Count - 1)].Attributes; + } + + protected override LocalisableString FormatCount(int count) => count.ToString(@"D"); + + protected override IHasText CreateText() => new TextComponent + { + Alpha = alpha_when_invalid + }; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (scoreProcessor != null) + scoreProcessor.NewJudgement -= onJudgementChanged; + + loadCancellationSource?.Cancel(); + } + + private class TextComponent : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + private readonly OsuSpriteText text; + + public TextComponent() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 16) + }, + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = @"pp", + Font = OsuFont.Numeric.With(size: 8), + Padding = new MarginPadding { Bottom = 1.5f }, // align baseline better + } + } + }; + } + } + + // TODO: This class shouldn't exist, but requires breaking changes to allow DifficultyCalculator to receive an IBeatmap. + private class GameplayWorkingBeatmap : WorkingBeatmap + { + private readonly IBeatmap gameplayBeatmap; + + public GameplayWorkingBeatmap(IBeatmap gameplayBeatmap) + : base(gameplayBeatmap.BeatmapInfo, null) + { + this.gameplayBeatmap = gameplayBeatmap; + } + + public override IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null, TimeSpan? timeout = null) + => gameplayBeatmap; + + protected override IBeatmap GetBeatmap() => gameplayBeatmap; + + protected override Texture GetBackground() => throw new NotImplementedException(); + + protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + + protected internal override ISkin GetSkin() => throw new NotImplementedException(); + + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs index b64e5ca98f..a2b84c79af 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -8,7 +8,6 @@ using Newtonsoft.Json; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Extensions; -using osu.Game.IO.Serialization; using osu.Game.Skinning; using osuTK; @@ -18,7 +17,7 @@ namespace osu.Game.Screens.Play.HUD /// Serialised information governing custom changes to an . /// [Serializable] - public class SkinnableInfo : IJsonSerializable + public class SkinnableInfo { public Type Type { get; set; } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index ffe03815f5..54c74a7177 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -11,6 +11,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Overlays; @@ -57,8 +58,6 @@ namespace osu.Game.Screens.Play private Bindable configVisibilityMode; - private readonly Container visibilityContainer; - private readonly BindableBool replayLoaded = new BindableBool(); private static bool hasShownNotificationOnce; @@ -72,7 +71,7 @@ namespace osu.Game.Screens.Play private readonly SkinnableTargetContainer mainComponents; - private IEnumerable hideTargets => new Drawable[] { visibilityContainer, KeyCounter, topRightElements }; + private IEnumerable hideTargets => new Drawable[] { mainComponents, KeyCounter, topRightElements }; public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods) { @@ -84,13 +83,9 @@ namespace osu.Game.Screens.Play Children = new Drawable[] { CreateFailingLayer(), - visibilityContainer = new Container + mainComponents = new SkinnableTargetContainer(SkinnableTarget.MainHUDComponents) { RelativeSizeAxes = Axes.Both, - Child = mainComponents = new SkinnableTargetContainer(SkinnableTarget.MainHUDComponents) - { - RelativeSizeAxes = Axes.Both, - }, }, topRightElements = new FillFlowContainer { @@ -282,14 +277,13 @@ namespace osu.Game.Screens.Play { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, }; protected PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.HoldForHUD: holdingForHUD = true; @@ -318,9 +312,9 @@ namespace osu.Game.Screens.Play return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.HoldForHUD: holdingForHUD = false; diff --git a/osu.Game/Screens/Play/HotkeyExitOverlay.cs b/osu.Game/Screens/Play/HotkeyExitOverlay.cs index 8d7e2481bf..13b72ffaf6 100644 --- a/osu.Game/Screens/Play/HotkeyExitOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyExitOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Overlays; @@ -9,17 +10,17 @@ namespace osu.Game.Screens.Play { public class HotkeyExitOverlay : HoldToConfirmOverlay, IKeyBindingHandler { - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action != GlobalAction.QuickExit) return false; + if (e.Action != GlobalAction.QuickExit) return false; BeginConfirm(); return true; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - if (action != GlobalAction.QuickExit) return; + if (e.Action != GlobalAction.QuickExit) return; AbortConfirm(); } diff --git a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs index 58fd941f36..308befe372 100644 --- a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Overlays; @@ -9,17 +10,17 @@ namespace osu.Game.Screens.Play { public class HotkeyRetryOverlay : HoldToConfirmOverlay, IKeyBindingHandler { - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - if (action != GlobalAction.QuickRetry) return false; + if (e.Action != GlobalAction.QuickRetry) return false; BeginConfirm(); return true; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - if (action != GlobalAction.QuickRetry) return; + if (e.Action != GlobalAction.QuickRetry) return; AbortConfirm(); } diff --git a/osu.Game/Screens/Play/ILocalUserPlayInfo.cs b/osu.Game/Screens/Play/ILocalUserPlayInfo.cs new file mode 100644 index 0000000000..9a2259b12f --- /dev/null +++ b/osu.Game/Screens/Play/ILocalUserPlayInfo.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; + +namespace osu.Game.Screens.Play +{ + [Cached] + public interface ILocalUserPlayInfo + { + /// + /// Whether the local user is currently playing. + /// + IBindable IsPlaying { get; } + } +} diff --git a/osu.Game/Screens/Play/KeyCounterDisplay.cs b/osu.Game/Screens/Play/KeyCounterDisplay.cs index 2ed4afafd3..66a44e5314 100644 --- a/osu.Game/Screens/Play/KeyCounterDisplay.cs +++ b/osu.Game/Screens/Play/KeyCounterDisplay.cs @@ -33,8 +33,6 @@ namespace osu.Game.Screens.Play public KeyCounterDisplay() { - AutoSizeAxes = Axes.Both; - InternalChild = KeyFlow = new FillFlowContainer { Direction = FillDirection.Horizontal, @@ -42,6 +40,15 @@ namespace osu.Game.Screens.Play }; } + protected override void Update() + { + base.Update(); + + // Don't use autosize as it will shrink to zero when KeyFlow is hidden. + // In turn this can cause the display to be masked off screen and never become visible again. + Size = KeyFlow.Size; + } + public override void Add(KeyCounter key) { if (key == null) throw new ArgumentNullException(nameof(key)); diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 3fbb55872b..aa46522dec 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -158,10 +158,10 @@ namespace osu.Game.Screens.Play { // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. - platformOffsetClock = new HardwareCorrectionOffsetClock(source) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; + platformOffsetClock = new HardwareCorrectionOffsetClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; // the final usable gameplay clock with user-set offsets applied. - userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); + userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock, pauseFreqAdjust); return masterGameplayClock = new MasterGameplayClock(userOffsetClock); } @@ -216,11 +216,25 @@ namespace osu.Game.Screens.Play { // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. // base implementation already adds offset at 1.0 rate, so we only add the difference from that here. - public override double CurrentTime => base.CurrentTime + Offset * (Rate - 1); + public override double CurrentTime => base.CurrentTime + offsetAdjust; - public HardwareCorrectionOffsetClock(IClock source, bool processSource = true) - : base(source, processSource) + private readonly BindableDouble pauseRateAdjust; + + private double offsetAdjust; + + public HardwareCorrectionOffsetClock(IClock source, BindableDouble pauseRateAdjust) + : base(source) { + this.pauseRateAdjust = pauseRateAdjust; + } + + public override void ProcessFrame() + { + base.ProcessFrame(); + + // changing this during the pause transform effect will cause a potentially large offset to be suddenly applied as we approach zero rate. + if (pauseRateAdjust.Value == 1) + offsetAdjust = Offset * (Rate - 1); } } diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index 8778cff535..e0b7e5c941 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -24,7 +24,7 @@ namespace osu.Game.Screens.Play private SkinnableSound pauseLoop; - protected override Action BackAction => () => InternalButtons.Children.First().Click(); + protected override Action BackAction => () => InternalButtons.Children.First().TriggerClick(); [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 97854ee12f..444bea049b 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -38,7 +37,7 @@ namespace osu.Game.Screens.Play { [Cached] [Cached(typeof(ISamplePlaybackDisabler))] - public abstract class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler + public abstract class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler, ILocalUserPlayInfo { /// /// The delay upon completion of the beatmap before displaying the results screen. @@ -47,7 +46,7 @@ namespace osu.Game.Screens.Play public override bool AllowBackButton => false; // handled by HoldForMenuButton - protected override UserActivity InitialActivity => new UserActivity.SoloGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); + protected override UserActivity InitialActivity => new UserActivity.InSoloGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); public override float BackgroundParallaxAmount => 0.1f; @@ -56,7 +55,7 @@ namespace osu.Game.Screens.Play protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; // We are managing our own adjustments (see OnEntering/OnExiting). - public override bool AllowRateAdjustments => false; + public override bool? AllowTrackAdjustments => false; private readonly IBindable gameActive = new Bindable(true); @@ -75,7 +74,9 @@ namespace osu.Game.Screens.Play private readonly Bindable storyboardReplacesBackground = new Bindable(); - protected readonly Bindable LocalUserPlaying = new Bindable(); + public IBindable LocalUserPlaying => localUserPlaying; + + private readonly Bindable localUserPlaying = new Bindable(); public int RestartCount; @@ -91,9 +92,9 @@ namespace osu.Game.Screens.Play [Resolved] private SpectatorClient spectatorClient { get; set; } - protected Ruleset GameplayRuleset { get; private set; } + public GameplayState GameplayState { get; private set; } - protected GameplayBeatmap GameplayBeatmap { get; private set; } + private Ruleset ruleset; private Sample sampleRestart; @@ -123,15 +124,11 @@ namespace osu.Game.Screens.Play public DimmableStoryboard DimmableStoryboard { get; private set; } - [Cached] - [Cached(Type = typeof(IBindable>))] - protected new readonly Bindable> Mods = new Bindable>(Array.Empty()); - /// /// Whether failing should be allowed. /// By default, this checks whether all selected mods allow failing. /// - protected virtual bool CheckModsAllowFailure() => Mods.Value.OfType().All(m => m.PerformFail()); + protected virtual bool CheckModsAllowFailure() => GameplayState.Mods.OfType().All(m => m.PerformFail()); public readonly PlayerConfiguration Configuration; @@ -159,13 +156,6 @@ namespace osu.Game.Screens.Play if (!LoadedBeatmapSuccessfully) return; - 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); @@ -184,12 +174,12 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader(true)] private void load(AudioManager audio, OsuConfigManager config, OsuGameBase game) { - Mods.Value = base.Mods.Value.Select(m => m.CreateCopy()).ToArray(); + var gameplayMods = Mods.Value.Select(m => m.DeepClone()).ToArray(); if (Beatmap.Value is DummyWorkingBeatmap) return; - IBeatmap playableBeatmap = loadPlayableBeatmap(); + IBeatmap playableBeatmap = loadPlayableBeatmap(gameplayMods); if (playableBeatmap == null) return; @@ -204,16 +194,16 @@ namespace osu.Game.Screens.Play if (game is OsuGame osuGame) LocalUserPlaying.BindTo(osuGame.LocalUserPlaying); - DrawableRuleset = GameplayRuleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value); + DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, gameplayMods); dependencies.CacheAs(DrawableRuleset); - ScoreProcessor = GameplayRuleset.CreateScoreProcessor(); + ScoreProcessor = ruleset.CreateScoreProcessor(); ScoreProcessor.ApplyBeatmap(playableBeatmap); - ScoreProcessor.Mods.BindTo(Mods); + ScoreProcessor.Mods.Value = gameplayMods; dependencies.CacheAs(ScoreProcessor); - HealthProcessor = GameplayRuleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); + HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); HealthProcessor.ApplyBeatmap(playableBeatmap); dependencies.CacheAs(HealthProcessor); @@ -223,12 +213,18 @@ namespace osu.Game.Screens.Play InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); - AddInternal(GameplayBeatmap = new GameplayBeatmap(playableBeatmap)); AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); - dependencies.CacheAs(GameplayBeatmap); + Score = CreateScore(playableBeatmap); - var rulesetSkinProvider = new RulesetSkinProvidingContainer(GameplayRuleset, playableBeatmap, Beatmap.Value.Skin); + // ensure the score is in a consistent state with the current player. + Score.ScoreInfo.BeatmapInfo = Beatmap.Value.BeatmapInfo; + Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; + Score.ScoreInfo.Mods = gameplayMods; + + dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score)); + + var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, 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. @@ -278,7 +274,7 @@ namespace osu.Game.Screens.Play { HealthProcessor.ApplyResult(r); ScoreProcessor.ApplyResult(r); - GameplayBeatmap.ApplyResult(r); + GameplayState.ApplyResult(r); }; DrawableRuleset.RevertResult += r => @@ -297,11 +293,19 @@ namespace osu.Game.Screens.Play ScoreProcessor.HasCompleted.BindValueChanged(scoreCompletionChanged); HealthProcessor.Failed += onFail; - foreach (var mod in Mods.Value.OfType()) - mod.ApplyToScoreProcessor(ScoreProcessor); + // Provide judgement processors to mods after they're loaded so that they're on the gameplay clock, + // this is required for mods that apply transforms to these processors. + ScoreProcessor.OnLoadComplete += _ => + { + foreach (var mod in gameplayMods.OfType()) + mod.ApplyToScoreProcessor(ScoreProcessor); + }; - foreach (var mod in Mods.Value.OfType()) - mod.ApplyToHealthProcessor(HealthProcessor); + HealthProcessor.OnLoadComplete += _ => + { + foreach (var mod in gameplayMods.OfType()) + mod.ApplyToHealthProcessor(HealthProcessor); + }; IsBreakTime.BindTo(breakTracker.IsBreakTime); IsBreakTime.BindValueChanged(onBreakTimeChanged, true); @@ -347,7 +351,7 @@ namespace osu.Game.Screens.Play // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), - HUDOverlay = new HUDOverlay(DrawableRuleset, Mods.Value) + HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods) { HoldToQuit = { @@ -434,7 +438,7 @@ namespace osu.Game.Screens.Play { bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !DrawableRuleset.IsPaused.Value && !breakTracker.IsBreakTime.Value; OverlayActivationMode.Value = inGameplay ? OverlayActivation.Disabled : OverlayActivation.UserTriggered; - LocalUserPlaying.Value = inGameplay; + localUserPlaying.Value = inGameplay; } private void updateSampleDisabledState() @@ -458,7 +462,7 @@ namespace osu.Game.Screens.Play } } - private IBeatmap loadPlayableBeatmap() + private IBeatmap loadPlayableBeatmap(Mod[] gameplayMods) { IBeatmap playable; @@ -468,19 +472,19 @@ namespace osu.Game.Screens.Play throw new InvalidOperationException("Beatmap was not loaded"); var rulesetInfo = Ruleset.Value ?? Beatmap.Value.BeatmapInfo.Ruleset; - GameplayRuleset = rulesetInfo.CreateInstance(); + ruleset = rulesetInfo.CreateInstance(); try { - playable = Beatmap.Value.GetPlayableBeatmap(GameplayRuleset.RulesetInfo, Mods.Value); + playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, gameplayMods); } 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; - GameplayRuleset = rulesetInfo.CreateInstance(); + ruleset = rulesetInfo.CreateInstance(); - playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, Mods.Value); + playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, gameplayMods); } if (playable.HitObjects.Count == 0) @@ -694,9 +698,11 @@ namespace osu.Game.Screens.Play /// The final score. private async Task prepareScoreForResults() { + var scoreCopy = Score.DeepClone(); + try { - await PrepareScoreForResultsAsync(Score).ConfigureAwait(false); + await PrepareScoreForResultsAsync(scoreCopy).ConfigureAwait(false); } catch (Exception ex) { @@ -705,14 +711,14 @@ namespace osu.Game.Screens.Play try { - await ImportScore(Score).ConfigureAwait(false); + await ImportScore(scoreCopy).ConfigureAwait(false); } catch (Exception ex) { Logger.Error(ex, @"Score import failed!"); } - return Score.ScoreInfo; + return scoreCopy.ScoreInfo; } /// @@ -778,7 +784,7 @@ namespace osu.Game.Screens.Play failAnimation.Start(); - if (Mods.Value.OfType().Any(m => m.RestartOnFail)) + if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) Restart(); return true; @@ -908,17 +914,17 @@ namespace osu.Game.Screens.Play storyboardReplacesBackground.Value = Beatmap.Value.Storyboard.ReplacesBackground && Beatmap.Value.Storyboard.HasDrawable; - foreach (var mod in Mods.Value.OfType()) + foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToPlayer(this); - foreach (var mod in Mods.Value.OfType()) + foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToHUD(HUDOverlay); // Our mods are local copies of the global mods so they need to be re-applied to the track. // This is done through the music controller (for now), because resetting speed adjustments on the beatmap track also removes adjustments provided by DrawableTrack. // Todo: In the future, player will receive in a track and will probably not have to worry about this... musicController.ResetTrackAdjustments(); - foreach (var mod in Mods.Value.OfType()) + foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToTrack(musicController.CurrentTrack); updateGameplayState(); @@ -941,7 +947,7 @@ namespace osu.Game.Screens.Play public override void OnSuspending(IScreen next) { - screenSuspension?.Expire(); + screenSuspension?.RemoveAndDisposeImmediately(); fadeOut(); base.OnSuspending(next); @@ -949,11 +955,16 @@ namespace osu.Game.Screens.Play public override bool OnExiting(IScreen next) { - screenSuspension?.Expire(); + screenSuspension?.RemoveAndDisposeImmediately(); + failAnimation?.RemoveAndDisposeImmediately(); // 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) + { 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. // To resolve test failures, forcefully end playing synchronously when this screen exits. @@ -973,8 +984,9 @@ namespace osu.Game.Screens.Play /// /// Creates the player's . /// + /// /// The . - protected virtual Score CreateScore() => new Score + protected virtual Score CreateScore(IBeatmap beatmap) => new Score { ScoreInfo = new ScoreInfo { User = api.LocalUser.Value }, }; @@ -994,7 +1006,7 @@ namespace osu.Game.Screens.Play using (var stream = new MemoryStream()) { - new LegacyScoreEncoder(score, GameplayBeatmap.PlayableBeatmap).Encode(stream); + new LegacyScoreEncoder(score, GameplayState.Beatmap).Encode(stream); replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); } @@ -1017,13 +1029,7 @@ namespace osu.Game.Screens.Play /// /// 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) - { - // perform one final population to ensure everything is up-to-date. - ScoreProcessor.PopulateScore(score.ScoreInfo); - - return Task.CompletedTask; - } + protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask; /// /// Creates the for a . @@ -1044,5 +1050,7 @@ namespace osu.Game.Screens.Play #endregion IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; + + IBindable ILocalUserPlayInfo.IsPlaying => LocalUserPlaying; } } diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index 18ee73374f..3aa424e5d5 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -20,6 +20,11 @@ namespace osu.Game.Screens.Play /// public bool AllowRestart { get; set; } = true; + /// + /// Whether the player should be able to interact with this player instance. + /// + public bool AllowUserInteraction { get; set; } = true; + /// /// Whether the player should be allowed to skip intros/outros, advancing to the start of gameplay or the end of a storyboard. /// diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 5f6b4ca2b0..cf5bff57cf 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.Transforms; using osu.Framework.Input; using osu.Framework.Screens; using osu.Framework.Threading; +using osu.Game.Audio.Effects; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -34,6 +35,8 @@ namespace osu.Game.Screens.Play { protected const float BACKGROUND_BLUR = 15; + private const double content_out_duration = 300; + public override bool HideOverlaysOnEnter => hideOverlays; public override bool DisallowExternalBeatmapRulesetChanges => true; @@ -46,9 +49,14 @@ namespace osu.Game.Screens.Play protected override bool PlayResumeSound => false; - protected BeatmapMetadataDisplay MetadataInfo; + protected BeatmapMetadataDisplay MetadataInfo { get; private set; } - protected VisualSettings VisualSettings; + /// + /// A fill flow containing the player settings groups, exposed for the ability to hide it from inheritors of the player loader. + /// + protected FillFlowContainer PlayerSettings { get; private set; } + + protected VisualSettings VisualSettings { get; private set; } protected Task LoadTask { get; private set; } @@ -58,6 +66,8 @@ namespace osu.Game.Screens.Play private readonly BindableDouble volumeAdjustment = new BindableDouble(1); + private AudioFilter lowPassFilter; + protected bool BackgroundBrightnessReduction { set @@ -122,40 +132,44 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader] - private void load(SessionStatics sessionStatics) + private void load(SessionStatics sessionStatics, AudioManager audio) { muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); - InternalChild = (content = new LogoTrackingContainer + InternalChildren = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - }).WithChildren(new Drawable[] - { - MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade) + (content = new LogoTrackingContainer { - Alpha = 0, Anchor = Anchor.Centre, Origin = Anchor.Centre, - }, - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + }).WithChildren(new Drawable[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Margin = new MarginPadding(25), - Children = new PlayerSettingsGroup[] + MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade) { - VisualSettings = new VisualSettings(), - new InputSettings() - } - }, - idleTracker = new IdleTracker(750) - }); + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + PlayerSettings = new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Margin = new MarginPadding(25), + Children = new PlayerSettingsGroup[] + { + VisualSettings = new VisualSettings(), + new InputSettings() + } + }, + idleTracker = new IdleTracker(750), + }), + lowPassFilter = new AudioFilter(audio.TrackMixer) + }; if (Beatmap.Value.BeatmapInfo.EpilepsyWarning) { @@ -224,14 +238,16 @@ namespace osu.Game.Screens.Play // stop the track before removing adjustment to avoid a volume spike. Beatmap.Value.Track.Stop(); Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF); } public override bool OnExiting(IScreen next) { cancelLoad(); + contentOut(); - content.ScaleTo(0.7f, 150, Easing.InQuint); - this.FadeOut(150); + // Ensure the screen doesn't expire until all the outwards fade operations have completed. + this.Delay(content_out_duration).FadeOut(); ApplyToBackground(b => b.IgnoreUserSettings.Value = true); @@ -332,6 +348,7 @@ namespace osu.Game.Screens.Play content.FadeInFromZero(400); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); + lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint); ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint)); } @@ -341,8 +358,9 @@ namespace osu.Game.Screens.Play // Ensure the logo is no longer tracking before we scale the content content.StopTracking(); - content.ScaleTo(0.7f, 300, Easing.InQuint); - content.FadeOut(250); + content.ScaleTo(0.7f, content_out_duration * 2, Easing.OutQuint); + content.FadeOut(content_out_duration, Easing.OutQuint); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, content_out_duration); } private void pushWhenLoaded() @@ -369,7 +387,7 @@ namespace osu.Game.Screens.Play contentOut(); - TransformSequence pushSequence = this.Delay(250); + TransformSequence pushSequence = this.Delay(content_out_duration); // only show if the warning was created (i.e. the beatmap needs it) // and this is not a restart of the map (the warning expires after first load). @@ -388,6 +406,11 @@ namespace osu.Game.Screens.Play }) .Delay(EpilepsyWarning.FADE_DURATION); } + else + { + // This goes hand-in-hand with the restoration of low pass filter in contentOut(). + this.TransformBindableTo(volumeAdjustment, 0, content_out_duration, Easing.OutCubic); + } pushSequence.Schedule(() => { diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index f70c05c2ff..93054b7bb5 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -3,11 +3,16 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +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; @@ -36,17 +41,31 @@ namespace osu.Game.Screens.Play DrawableRuleset?.SetReplayScore(Score); } - protected override Score CreateScore() => createScore(GameplayBeatmap.PlayableBeatmap, Mods.Value); + protected override Score CreateScore(IBeatmap beatmap) => createScore(beatmap, 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); - public bool OnPressed(GlobalAction action) + private ScheduledDelegate keyboardSeekDelegate; + + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + const double keyboard_seek_amount = 5000; + + switch (e.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(); @@ -56,10 +75,24 @@ namespace osu.Game.Screens.Play } return false; + + void keyboardSeek(int direction) + { + double target = Math.Clamp(GameplayClockContainer.CurrentTime + direction * keyboard_seek_amount, 0, GameplayState.Beatmap.HitObjects.Last().GetEndTime()); + + Seek(target); + } } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { + switch (e.Action) + { + case GlobalAction.SeekReplayBackward: + case GlobalAction.SeekReplayForward: + keyboardSeekDelegate?.Cancel(); + break; + } } } } diff --git a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs index 7ba12f5db6..1002e7607f 100644 --- a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs +++ b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs @@ -1,8 +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.Bindables; +using System.Diagnostics; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Scoring; @@ -14,25 +13,28 @@ namespace osu.Game.Screens.Play /// public abstract class RoomSubmittingPlayer : SubmittingPlayer { - [Resolved(typeof(Room), nameof(Room.RoomID))] - protected Bindable RoomId { get; private set; } - protected readonly PlaylistItem PlaylistItem; + protected readonly Room Room; - protected RoomSubmittingPlayer(PlaylistItem playlistItem, PlayerConfiguration configuration = null) + protected RoomSubmittingPlayer(Room room, PlaylistItem playlistItem, PlayerConfiguration configuration = null) : base(configuration) { + Room = room; PlaylistItem = playlistItem; } protected override APIRequest CreateTokenRequest() { - if (!(RoomId.Value is long roomId)) + if (!(Room.RoomID.Value is long roomId)) return null; return new CreateRoomScoreRequest(roomId, PlaylistItem.ID, Game.VersionHash); } - protected override APIRequest CreateSubmissionRequest(Score score, long token) => new SubmitRoomScoreRequest(token, RoomId.Value ?? 0, PlaylistItem.ID, score.ScoreInfo); + protected override APIRequest CreateSubmissionRequest(Score score, long token) + { + Debug.Assert(Room.RoomID.Value != null); + return new SubmitRoomScoreRequest(token, Room.RoomID.Value.Value, PlaylistItem.ID, score.ScoreInfo); + } } } diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index ed49fc40b2..b04fcba0c6 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -37,6 +36,8 @@ namespace osu.Game.Screens.Play private FadeContainer fadeContainer; private double displayTime; + private bool isClickable; + [Resolved] private GameplayClock gameplayClock { get; set; } @@ -101,7 +102,7 @@ namespace osu.Game.Screens.Play public override void Show() { base.Show(); - fadeContainer.Show(); + fadeContainer.TriggerShow(); } protected override void LoadComplete() @@ -118,44 +119,47 @@ namespace osu.Game.Screens.Play button.Action = () => RequestSkip?.Invoke(); displayTime = gameplayClock.CurrentTime; + + fadeContainer.TriggerShow(); } protected override void Update() { base.Update(); - var progress = fadeOutBeginTime <= displayTime ? 1 : Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); + double progress = fadeOutBeginTime <= displayTime ? 1 : Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); remainingTimeBox.Width = (float)Interpolation.Lerp(remainingTimeBox.Width, progress, Math.Clamp(Time.Elapsed / 40, 0, 1)); - button.Enabled.Value = progress > 0; - buttonContainer.State.Value = progress > 0 ? Visibility.Visible : Visibility.Hidden; + isClickable = progress > 0; + button.Enabled.Value = isClickable; + buttonContainer.State.Value = isClickable ? Visibility.Visible : Visibility.Hidden; } protected override bool OnMouseMove(MouseMoveEvent e) { - if (!e.HasAnyButtonPressed) - fadeContainer.Show(); + if (isClickable && !e.HasAnyButtonPressed) + fadeContainer.TriggerShow(); return base.OnMouseMove(e); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.SkipCutscene: if (!button.Enabled.Value) return false; - button.Click(); + button.TriggerClick(); return true; } return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -164,34 +168,45 @@ namespace osu.Game.Screens.Play public event Action StateChanged; private Visibility state; - private ScheduledDelegate scheduledHide; + private double? nextHideTime; public override bool IsPresent => true; + public void TriggerShow() + { + Show(); + + if (!IsHovered && !IsDragged) + nextHideTime = Time.Current + 1000; + else + nextHideTime = null; + } + + protected override void Update() + { + base.Update(); + + if (nextHideTime != null && nextHideTime <= Time.Current) + { + Hide(); + nextHideTime = null; + } + } + public Visibility State { get => state; set { - bool stateChanged = value != state; + if (value == state) + return; state = value; - scheduledHide?.Cancel(); - switch (state) { case Visibility.Visible: - // we may be triggered to become visible multiple times but we only want to transform once. - if (stateChanged) - this.FadeIn(500, Easing.OutExpo); - - if (!IsHovered && !IsDragged) - { - using (BeginDelayedSequence(1000)) - scheduledHide = Schedule(Hide); - } - + this.FadeIn(500, Easing.OutExpo); break; case Visibility.Hidden: @@ -212,7 +227,7 @@ namespace osu.Game.Screens.Play protected override bool OnMouseDown(MouseDownEvent e) { Show(); - scheduledHide?.Cancel(); + nextHideTime = null; return true; } diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index ef1087dd62..675cb71311 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -6,6 +6,7 @@ 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 @@ -27,7 +28,7 @@ namespace osu.Game.Screens.Play 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); @@ -37,7 +38,7 @@ namespace osu.Game.Screens.Play protected override APIRequest CreateSubmissionRequest(Score score, long token) { - var beatmap = score.ScoreInfo.Beatmap; + var beatmap = score.ScoreInfo.BeatmapInfo; Debug.Assert(beatmap.OnlineBeatmapID != null); diff --git a/osu.Game/Screens/Play/SoloSpectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs index 820d776e63..9d4dad8bdc 100644 --- a/osu.Game/Screens/Play/SoloSpectator.cs +++ b/osu.Game/Screens/Play/SoloSpectator.cs @@ -56,7 +56,7 @@ namespace osu.Game.Screens.Play /// The player's immediate online gameplay state. /// This doesn't always reflect the gameplay state being watched. /// - private GameplayState immediateGameplayState; + private SpectatorGameplayState immediateSpectatorGameplayState; private GetBeatmapSetRequest onlineBeatmapRequest; @@ -146,7 +146,7 @@ namespace osu.Game.Screens.Play Width = 250, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Action = () => scheduleStart(immediateGameplayState), + Action = () => scheduleStart(immediateSpectatorGameplayState), Enabled = { Value = false } } } @@ -167,18 +167,18 @@ namespace osu.Game.Screens.Play showBeatmapPanel(spectatorState); } - protected override void StartGameplay(int userId, GameplayState gameplayState) + protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState) { - immediateGameplayState = gameplayState; + immediateSpectatorGameplayState = spectatorGameplayState; watchButton.Enabled.Value = true; - scheduleStart(gameplayState); + scheduleStart(spectatorGameplayState); } protected override void EndGameplay(int userId) { scheduledStart?.Cancel(); - immediateGameplayState = null; + immediateSpectatorGameplayState = null; watchButton.Enabled.Value = false; clearDisplay(); @@ -194,7 +194,7 @@ namespace osu.Game.Screens.Play private ScheduledDelegate scheduledStart; - private void scheduleStart(GameplayState gameplayState) + private void scheduleStart(SpectatorGameplayState spectatorGameplayState) { // This function may be called multiple times in quick succession once the screen becomes current again. scheduledStart?.Cancel(); @@ -203,15 +203,15 @@ namespace osu.Game.Screens.Play if (this.IsCurrentScreen()) start(); else - scheduleStart(gameplayState); + scheduleStart(spectatorGameplayState); }); void start() { - Beatmap.Value = gameplayState.Beatmap; - Ruleset.Value = gameplayState.Ruleset.RulesetInfo; + Beatmap.Value = spectatorGameplayState.Beatmap; + Ruleset.Value = spectatorGameplayState.Ruleset.RulesetInfo; - this.Push(new SpectatorPlayerLoader(gameplayState.Score)); + this.Push(new SpectatorPlayerLoader(spectatorGameplayState.Score, () => new SoloSpectatorPlayer(spectatorGameplayState.Score))); } } diff --git a/osu.Game/Screens/Play/SoloSpectatorPlayer.cs b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs new file mode 100644 index 0000000000..969a5bf2b4 --- /dev/null +++ b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Screens; +using osu.Game.Online.Spectator; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Play +{ + public class SoloSpectatorPlayer : SpectatorPlayer + { + private readonly Score score; + + public SoloSpectatorPlayer(Score score, PlayerConfiguration configuration = null) + : base(score, configuration) + { + this.score = score; + } + + [BackgroundDependencyLoader] + private void load() + { + SpectatorClient.OnUserBeganPlaying += userBeganPlaying; + } + + public override bool OnExiting(IScreen next) + { + SpectatorClient.OnUserBeganPlaying -= userBeganPlaying; + + return base.OnExiting(next); + } + + private void userBeganPlaying(int userId, SpectatorState state) + { + if (userId != score.ScoreInfo.UserID) return; + + Schedule(() => + { + if (this.IsCurrentScreen()) this.Exit(); + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (SpectatorClient != null) + SpectatorClient.OnUserBeganPlaying -= userBeganPlaying; + } + } +} diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index bd861dc598..b27a9c5f5d 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -119,7 +119,8 @@ namespace osu.Game.Screens.Play if (drawableRuleset != null) { - AllowSeeking.BindTo(drawableRuleset.HasReplayLoaded); + if (player?.Configuration.AllowUserInteraction == true) + ((IBindable)AllowSeeking).BindTo(drawableRuleset.HasReplayLoaded); referenceClock = drawableRuleset.FrameStableClock; Objects = drawableRuleset.Objects; @@ -178,7 +179,7 @@ namespace osu.Game.Screens.Play float barHeight = bottom_bar_height + handle_size.Y; bar.ResizeHeightTo(ShowGraph.Value ? barHeight + graph_height : barHeight, transition_duration, Easing.In); - graph.MoveToY(ShowGraph.Value ? 0 : bottom_bar_height + graph_height, transition_duration, Easing.In); + graph.FadeTo(ShowGraph.Value ? 1 : 0, transition_duration, Easing.In); updateInfoMargin(); } 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 f662a479ec..f6a89e7fa9 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -4,6 +4,7 @@ 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; @@ -14,16 +15,17 @@ using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { - public class SpectatorPlayer : Player + public abstract class SpectatorPlayer : Player { [Resolved] - private SpectatorClient spectatorClient { get; set; } + protected SpectatorClient SpectatorClient { get; private set; } private readonly Score score; protected override bool CheckModsAllowFailure() => false; // todo: better support starting mid-way through beatmap - public SpectatorPlayer(Score score) + protected SpectatorPlayer(Score score, PlayerConfiguration configuration = null) + : base(configuration) { this.score = score; } @@ -31,8 +33,6 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader] private void load() { - spectatorClient.OnUserBeganPlaying += userBeganPlaying; - AddInternal(new OsuSpriteText { Text = $"Watching {score.ScoreInfo.User.Username} playing live!", @@ -49,7 +49,7 @@ namespace osu.Game.Screens.Play // Start gameplay along with the very first arrival frame (the latest one). score.Replay.Frames.Clear(); - spectatorClient.OnNewFrames += userSentFrames; + SpectatorClient.OnNewFrames += userSentFrames; } private void userSentFrames(int userId, FrameDataBundle bundle) @@ -67,8 +67,8 @@ namespace osu.Game.Screens.Play foreach (var frame in bundle.Frames) { - IConvertibleReplayFrame convertibleFrame = GameplayRuleset.CreateConvertibleReplayFrame(); - convertibleFrame.FromLegacy(frame, GameplayBeatmap.PlayableBeatmap); + IConvertibleReplayFrame convertibleFrame = GameplayState.Ruleset.CreateConvertibleReplayFrame(); + convertibleFrame.FromLegacy(frame, GameplayState.Beatmap); var convertedFrame = (ReplayFrame)convertibleFrame; convertedFrame.Time = frame.Time; @@ -80,7 +80,7 @@ namespace osu.Game.Screens.Play NonFrameStableSeek(score.Replay.Frames[0].Time); } - protected override Score CreateScore() => score; + protected override Score CreateScore(IBeatmap beatmap) => score; protected override ResultsScreen CreateResults(ScoreInfo score) => new SpectatorResultsScreen(score); @@ -92,31 +92,17 @@ namespace osu.Game.Screens.Play public override bool OnExiting(IScreen next) { - spectatorClient.OnUserBeganPlaying -= userBeganPlaying; - spectatorClient.OnNewFrames -= userSentFrames; + SpectatorClient.OnNewFrames -= userSentFrames; return base.OnExiting(next); } - private void userBeganPlaying(int userId, SpectatorState state) - { - if (userId != score.ScoreInfo.UserID) return; - - Schedule(() => - { - if (this.IsCurrentScreen()) this.Exit(); - }); - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (spectatorClient != null) - { - spectatorClient.OnUserBeganPlaying -= userBeganPlaying; - spectatorClient.OnNewFrames -= userSentFrames; - } + if (SpectatorClient != null) + SpectatorClient.OnNewFrames -= userSentFrames; } } } diff --git a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs index bdd23962dc..10cc36c9a9 100644 --- a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs +++ b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs @@ -11,12 +11,7 @@ namespace osu.Game.Screens.Play { public readonly ScoreInfo Score; - public SpectatorPlayerLoader(Score score) - : this(score, () => new SpectatorPlayer(score)) - { - } - - public SpectatorPlayerLoader(Score score, Func createPlayer) + public SpectatorPlayerLoader(Score score, Func createPlayer) : base(createPlayer) { if (score.Replay == null) diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 76e9f28dae..5faa384d03 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -109,6 +109,8 @@ namespace osu.Game.Screens.Play { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); + score.ScoreInfo.Date = DateTimeOffset.Now; + await submitScore(score).ConfigureAwait(false); } @@ -116,7 +118,7 @@ namespace osu.Game.Screens.Play { var exiting = base.OnExiting(next); - submitScore(Score); + submitScore(Score.DeepClone()); return exiting; } diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs index 7e8dcdcfe0..20c603295b 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -131,14 +131,14 @@ namespace osu.Game.Screens.Ranking.Contracted createStatistic("Accuracy", $"{score.Accuracy.FormatAccuracy()}"), } }, - new ModDisplay + new ModFlowDisplay { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - ExpansionMode = ExpansionMode.AlwaysExpanded, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, Current = { Value = score.Mods }, - Scale = new Vector2(0.5f), + IconScale = 0.5f, } } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 0a10eee644..262d1e8293 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -8,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -58,7 +60,7 @@ namespace osu.Game.Screens.Ranking.Expanded [BackgroundDependencyLoader] private void load(BeatmapDifficultyCache beatmapDifficultyCache) { - var beatmap = score.Beatmap; + var beatmap = score.BeatmapInfo; var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; var creator = metadata.Author?.Username; @@ -79,162 +81,155 @@ namespace osu.Game.Screens.Ranking.Expanded var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).Result; - InternalChildren = new Drawable[] + AddInternal(new FillFlowContainer { - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(20), - Children = new Drawable[] + new FillFlowContainer { - new FillFlowContainer + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new OsuSpriteText { - new OsuSpriteText + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), + Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 40 }, + RelativeSizeAxes = Axes.X, + Height = 230, + Child = new AccuracyCircle(score, withFlair) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), - Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), - MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, - }, - new OsuSpriteText + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + } + }, + scoreCounter = new TotalScoreCounter + { + Margin = new MarginPadding { Top = 0, Bottom = 5 }, + Current = { Value = 0 }, + Alpha = 0, + AlwaysPresent = true + }, + starAndModDisplay = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5, 0), + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), - MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, - }, - new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 40 }, - RelativeSizeAxes = Axes.X, - Height = 230, - Child = new AccuracyCircle(score, withFlair) + new StarRatingDisplay(starDifficulty) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - } - }, - scoreCounter = new TotalScoreCounter + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + } + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] { - Margin = new MarginPadding { Top = 0, Bottom = 5 }, - Current = { Value = 0 }, - Alpha = 0, - AlwaysPresent = true - }, - starAndModDisplay = new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5, 0), - Children = new Drawable[] + new OsuSpriteText { - new StarRatingDisplay(starDifficulty) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - } - }, - new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = beatmap.Version, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + }, + new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) { - new OsuSpriteText + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }.With(t => + { + if (!string.IsNullOrEmpty(creator)) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = beatmap.Version, - Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), - }, - new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - }.With(t => - { - if (!string.IsNullOrEmpty(creator)) - { - t.AddText("mapped by "); - t.AddText(creator, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); - } - }) - } - }, - } - }, - new FillFlowContainer + t.AddText("mapped by "); + t.AddText(creator, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + } + }) + } + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - Children = new Drawable[] + new GridContainer { - new GridContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { topStatistics.Cast().ToArray() }, + RowDimensions = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] { topStatistics.Cast().ToArray() }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - } - }, - new GridContainer + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Where(s => s.Result <= HitResult.Perfect).ToArray() }, + RowDimensions = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] { bottomStatistics.Where(s => s.Result <= HitResult.Perfect).ToArray() }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - } - }, - new GridContainer + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Where(s => s.Result > HitResult.Perfect).ToArray() }, + RowDimensions = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] { bottomStatistics.Where(s => s.Result > HitResult.Perfect).ToArray() }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - } + new Dimension(GridSizeMode.AutoSize), } } } } - }, - new OsuSpriteText - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), - Text = $"Played on {score.Date.ToLocalTime():d MMMM yyyy HH:mm}" } - }; + }); + + if (score.Date != default) + AddInternal(new PlayedOnText(score.Date)); if (score.Mods.Any()) { @@ -256,7 +251,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); @@ -265,7 +260,7 @@ namespace osu.Game.Screens.Ranking.Expanded foreach (var stat in statisticDisplays) { - using (BeginDelayedSequence(delay, true)) + using (BeginDelayedSequence(delay)) stat.Appear(); delay += 200; @@ -276,5 +271,16 @@ namespace osu.Game.Screens.Ranking.Expanded FinishTransforms(true); }); } + + public class PlayedOnText : OsuSpriteText + { + public PlayedOnText(DateTimeOffset time) + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold); + Text = $"Played on {time.ToLocalTime():d MMMM yyyy HH:mm}"; + } + } } } diff --git a/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs deleted file mode 100644 index e59a0de316..0000000000 --- a/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs +++ /dev/null @@ -1,130 +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.Globalization; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Ranking.Expanded -{ - /// - /// A pill that displays the star rating of a . - /// - public class StarRatingDisplay : CompositeDrawable, IHasCurrentValue - { - private Box background; - private OsuTextFlowContainer textFlow; - - [Resolved] - private OsuColour colours { get; set; } - - private readonly BindableWithCurrent current = new BindableWithCurrent(); - - public Bindable Current - { - get => current.Current; - set => current.Current = value; - } - - /// - /// Creates a new using an already computed . - /// - /// The already computed to display the star difficulty of. - public StarRatingDisplay(StarDifficulty starDifficulty) - { - Current.Value = starDifficulty; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours, BeatmapDifficultyCache difficultyCache) - { - AutoSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - }, - } - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 8, Vertical = 4 }, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(2, 0), - Children = new Drawable[] - { - new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(7), - Icon = FontAwesome.Solid.Star, - Colour = Color4.Black - }, - textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.Numeric.With(weight: FontWeight.Black)) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - TextAnchor = Anchor.BottomLeft, - } - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Current.BindValueChanged(_ => updateDisplay(), true); - } - - private void updateDisplay() - { - var starRatingParts = Current.Value.Stars.ToString("0.00", CultureInfo.InvariantCulture).Split('.'); - string wholePart = starRatingParts[0]; - string fractionPart = starRatingParts[1]; - string separator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; - - var rating = Current.Value.DifficultyRating; - - background.Colour = colours.ForDifficultyRating(rating, true); - - textFlow.Clear(); - textFlow.AddText($"{wholePart}", s => - { - s.Colour = Color4.Black; - s.Font = s.Font.With(size: 14); - s.UseFullGlyphHeight = false; - }); - - textFlow.AddText($"{separator}{fractionPart}", s => - { - s.Colour = Color4.Black; - s.Font = s.Font.With(size: 7); - s.UseFullGlyphHeight = false; - }); - } - } -} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs index 288a107874..476c9fb42f 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -44,7 +45,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; - protected override string FormatCount(double count) => count.FormatAccuracy(); + protected override LocalisableString FormatCount(double count) => count.FormatAccuracy(); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => { 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/Expanded/TotalScoreCounter.cs b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs index 65082d3fae..c54bca9e3a 100644 --- a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs +++ b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -26,7 +27,7 @@ namespace osu.Game.Screens.Ranking.Expanded RelativeSizeAxes = Axes.X; } - protected override string FormatCount(long count) => count.ToString("N0"); + protected override LocalisableString FormatCount(long count) => count.ToString("N0"); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => { diff --git a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs index 18b8649a59..e644eb671a 100644 --- a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs +++ b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs @@ -60,7 +60,7 @@ namespace osu.Game.Screens.Ranking break; case DownloadState.NotDownloaded: - scores.Download(Model.Value); + scores.Download(Model.Value, false); break; case DownloadState.Importing: diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index b458d7c17f..af60296344 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -40,6 +41,8 @@ namespace osu.Game.Screens.Ranking protected ScorePanelList ScorePanelList { get; private set; } + protected VerticalScrollContainer VerticalScrollContent { get; private set; } + [Resolved(CanBeNull = true)] private Player player { get; set; } @@ -50,8 +53,7 @@ namespace osu.Game.Screens.Ranking private Drawable bottomPanel; private Container detachedPanelContainer; - private bool fetchedInitialScores; - private APIRequest nextPageRequest; + private bool lastFetchCompleted; private readonly bool allowRetry; private readonly bool allowWatchingReplay; @@ -77,7 +79,7 @@ namespace osu.Game.Screens.Ranking { new Drawable[] { - new VerticalScrollContainer + VerticalScrollContent = new VerticalScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, @@ -189,8 +191,10 @@ namespace osu.Game.Screens.Ranking { base.Update(); - if (fetchedInitialScores && nextPageRequest == null) + if (lastFetchCompleted) { + APIRequest nextPageRequest = null; + if (ScorePanelList.IsScrolledToStart) nextPageRequest = FetchNextPage(-1, fetchScoresCallback); else if (ScorePanelList.IsScrolledToEnd) @@ -198,10 +202,7 @@ namespace osu.Game.Screens.Ranking if (nextPageRequest != null) { - // Scheduled after children to give the list a chance to update its scroll position and not potentially trigger a second request too early. - nextPageRequest.Success += () => ScheduleAfterChildren(() => nextPageRequest = null); - nextPageRequest.Failure += _ => ScheduleAfterChildren(() => nextPageRequest = null); - + lastFetchCompleted = false; api.Queue(nextPageRequest); } } @@ -227,7 +228,7 @@ namespace osu.Game.Screens.Ranking foreach (var s in scores) addScore(s); - fetchedInitialScores = true; + lastFetchCompleted = true; }); public override void OnEntering(IScreen last) @@ -327,9 +328,9 @@ namespace osu.Game.Screens.Ranking } } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Select: statisticsPanel.ToggleVisibility(); @@ -339,11 +340,11 @@ namespace osu.Game.Screens.Ranking return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } - private class VerticalScrollContainer : OsuScrollContainer + protected class VerticalScrollContainer : OsuScrollContainer { protected override Container Content => content; @@ -351,6 +352,8 @@ namespace osu.Game.Screens.Ranking public VerticalScrollContainer() { + Masking = false; + base.Content.Add(content = new Container { RelativeSizeAxes = Axes.X }); } 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 e170241ede..d5b8a4c8ea 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -5,10 +5,15 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; using osu.Game.Scoring; using osuTK; @@ -36,12 +41,14 @@ namespace osu.Game.Screens.Ranking /// /// Whether this can be scrolled and is currently scrolled to the start. /// - public bool IsScrolledToStart => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.Current <= scroll_endpoint_distance; + public bool IsScrolledToStart => flow.Count > 0 && AllPanelsVisible && scroll.ScrollableExtent > 0 && scroll.Current <= scroll_endpoint_distance; /// /// Whether this can be scrolled and is currently scrolled to the end. /// - public bool IsScrolledToEnd => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.IsScrolledToEnd(scroll_endpoint_distance); + public bool IsScrolledToEnd => flow.Count > 0 && AllPanelsVisible && scroll.ScrollableExtent > 0 && scroll.IsScrolledToEnd(scroll_endpoint_distance); + + public bool AllPanelsVisible => flow.All(p => p.IsPresent); /// /// The current scroll position. @@ -60,6 +67,13 @@ namespace osu.Game.Screens.Ranking public readonly Bindable SelectedScore = new Bindable(); + [Resolved] + private ScoreManager scoreManager { get; set; } + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + + private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private readonly Flow flow; private readonly Scroll scroll; private ScorePanel expandedPanel; @@ -90,6 +104,9 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); + foreach (var d in flow) + displayScore(d); + SelectedScore.BindValueChanged(selectedScoreChanged, true); } @@ -114,36 +131,56 @@ namespace osu.Game.Screens.Ranking }; }); - flow.Add(panel.CreateTrackingContainer().With(d => + var trackingContainer = panel.CreateTrackingContainer().With(d => { d.Anchor = Anchor.Centre; d.Origin = Anchor.Centre; - })); + d.Hide(); + }); + + flow.Add(trackingContainer); if (IsLoaded) - { - if (SelectedScore.Value == score) - { - SelectedScore.TriggerChange(); - } - else - { - // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. - // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. - if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) - { - // A somewhat hacky property is used here because we need to: - // 1) Scroll after the scroll container's visible range is updated. - // 2) Scroll before the scroll container's scroll position is updated. - // Without this, we would have a 1-frame positioning error which looks very jarring. - scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; - } - } - } + displayScore(trackingContainer); return panel; } + private void displayScore(ScorePanelTrackingContainer trackingContainer) + { + if (!IsLoaded) + return; + + var score = trackingContainer.Panel.Score; + + // Calculating score can take a while in extreme scenarios, so only display scores after the process completes. + scoreManager.GetTotalScoreAsync(score) + .ContinueWith(totalScore => Schedule(() => + { + flow.SetLayoutPosition(trackingContainer, totalScore.Result); + + trackingContainer.Show(); + + if (SelectedScore.Value == score) + { + SelectedScore.TriggerChange(); + } + else + { + // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. + // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. + if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) + { + // A somewhat hacky property is used here because we need to: + // 1) Scroll after the scroll container's visible range is updated. + // 2) Scroll before the scroll container's scroll position is updated. + // Without this, we would have a 1-frame positioning error which looks very jarring. + scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; + } + } + }), TaskContinuationOptions.OnlyOnRanToCompletion); + } + /// /// Brings a to the centre of the screen and expands it. /// @@ -267,49 +304,48 @@ namespace osu.Game.Screens.Ranking protected override bool OnKeyDown(KeyDownEvent e) { - var expandedPanelIndex = flow.GetPanelIndex(expandedPanel.Score); + if (expandedPanel == null) + return base.OnKeyDown(e); switch (e.Key) { case Key.Left: - if (expandedPanelIndex > 0) - SelectedScore.Value = flow.Children[expandedPanelIndex - 1].Panel.Score; + var previousScore = flow.GetPreviousScore(expandedPanel.Score); + if (previousScore != null) + SelectedScore.Value = previousScore; return true; case Key.Right: - if (expandedPanelIndex < flow.Count - 1) - SelectedScore.Value = flow.Children[expandedPanelIndex + 1].Panel.Score; + var nextScore = flow.GetNextScore(expandedPanel.Score); + if (nextScore != null) + SelectedScore.Value = nextScore; return true; } return base.OnKeyDown(e); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + loadCancellationSource?.Cancel(); + } + private class Flow : FillFlowContainer { public override IEnumerable FlowingChildren => applySorting(AliveInternalChildren); public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Panel.Score != score).Count(); + [CanBeNull] + public ScoreInfo GetPreviousScore(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Panel.Score != score).LastOrDefault()?.Panel.Score; + + [CanBeNull] + public ScoreInfo GetNextScore(ScoreInfo score) => applySorting(Children).SkipWhile(s => s.Panel.Score != score).ElementAtOrDefault(1)?.Panel.Score; + private IEnumerable applySorting(IEnumerable drawables) => drawables.OfType() - .OrderByDescending(s => s.Panel.Score.TotalScore) + .OrderByDescending(GetLayoutPosition) .ThenBy(s => s.Panel.Score.OnlineScoreID); - - protected override int Compare(Drawable x, Drawable y) - { - var tX = (ScorePanelTrackingContainer)x; - var tY = (ScorePanelTrackingContainer)y; - - int result = tY.Panel.Score.TotalScore.CompareTo(tX.Panel.Score.TotalScore); - - if (result != 0) - return result; - - if (tX.Panel.Score.OnlineScoreID == null || tY.Panel.Score.OnlineScoreID == null) - return base.Compare(x, y); - - return tX.Panel.Score.OnlineScoreID.Value.CompareTo(tY.Panel.Score.OnlineScoreID.Value); - } } private class Scroll : OsuScrollContainer diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 9bc696948f..5e582a8dcb 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -27,10 +27,10 @@ namespace osu.Game.Screens.Ranking protected override APIRequest FetchScores(Action> scoresCallback) { - if (Score.Beatmap.OnlineBeatmapID == null || Score.Beatmap.Status <= BeatmapSetOnlineStatus.Pending) + if (Score.BeatmapInfo.OnlineBeatmapID == null || Score.BeatmapInfo.Status <= BeatmapSetOnlineStatus.Pending) return null; - getScoreRequest = new GetScoresRequest(Score.Beatmap, Score.Ruleset); + getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineScoreID != Score.OnlineScoreID).Select(s => s.CreateScoreInfo(rulesets))); return getScoreRequest; } diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index f1ae1f9d73..bc62bcf2b2 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -102,7 +102,7 @@ namespace osu.Game.Screens.Ranking.Statistics // Todo: The placement of this is temporary. Eventually we'll both generate the playable beatmap _and_ run through it in a background task to generate the hit events. Task.Run(() => { - playableBeatmap = beatmapManager.GetWorkingBeatmap(newScore.Beatmap).GetPlayableBeatmap(newScore.Ruleset, newScore.Mods ?? Array.Empty()); + playableBeatmap = beatmapManager.GetWorkingBeatmap(newScore.BeatmapInfo).GetPlayableBeatmap(newScore.Ruleset, newScore.Mods ?? Array.Empty()); }, loadCancellation.Token).ContinueWith(t => Schedule(() => { var rows = new FillFlowContainer 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/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index b05b7aeb32..e5e28d2fde 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -48,7 +48,7 @@ namespace osu.Game.Screens.Select /// /// The currently selected beatmap. /// - public BeatmapInfo SelectedBeatmap => selectedBeatmap?.Beatmap; + public BeatmapInfo SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo; private CarouselBeatmap selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected); @@ -65,7 +65,7 @@ namespace osu.Game.Screens.Select private CarouselBeatmapSet selectedBeatmapSet; /// - /// Raised when the is changed. + /// Raised when the is changed. /// public Action SelectionChanged; @@ -212,7 +212,7 @@ namespace osu.Game.Screens.Select // If the selected beatmap is about to be removed, store its ID so it can be re-selected if required if (existingSet?.State?.Value == CarouselItemState.Selected) - previouslySelectedID = selectedBeatmap?.Beatmap.ID; + previouslySelectedID = selectedBeatmap?.BeatmapInfo.ID; var newSet = createCarouselSet(beatmapSet); @@ -233,7 +233,7 @@ namespace osu.Game.Screens.Select // check if we can/need to maintain our current selection. if (previouslySelectedID != null) - select((CarouselItem)newSet.Beatmaps.FirstOrDefault(b => b.Beatmap.ID == previouslySelectedID) ?? newSet); + select((CarouselItem)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet); itemsCache.Invalidate(); Schedule(() => BeatmapSetsChanged?.Invoke()); @@ -242,15 +242,15 @@ namespace osu.Game.Screens.Select /// /// Selects a given beatmap on the carousel. /// - /// The beatmap to select. + /// The beatmap to select. /// Whether to select the beatmap even if it is filtered (i.e., not visible on carousel). /// True if a selection was made, False if it wasn't. - public bool SelectBeatmap(BeatmapInfo beatmap, bool bypassFilters = true) + public bool SelectBeatmap(BeatmapInfo beatmapInfo, bool bypassFilters = true) { // ensure that any pending events from BeatmapManager have been run before attempting a selection. Scheduler.Update(); - if (beatmap?.Hidden != false) + if (beatmapInfo?.Hidden != false) return false; foreach (CarouselBeatmapSet set in beatmapSets) @@ -258,7 +258,7 @@ namespace osu.Game.Screens.Select if (!bypassFilters && set.Filtered.Value) continue; - var item = set.Beatmaps.FirstOrDefault(p => p.Beatmap.Equals(beatmap)); + var item = set.Beatmaps.FirstOrDefault(p => p.BeatmapInfo.Equals(beatmapInfo)); if (item == null) // The beatmap that needs to be selected doesn't exist in this set @@ -472,7 +472,7 @@ namespace osu.Game.Screens.Select private float? scrollTarget; /// - /// Scroll to the current . + /// Scroll to the current . /// /// /// Whether the scroll position should immediately be shifted to the target, delegating animation to visible panels. @@ -514,29 +514,29 @@ namespace osu.Game.Screens.Select base.OnKeyUp(e); } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.SelectNext: - beginRepeatSelection(() => SelectNext(1, false), action); + beginRepeatSelection(() => SelectNext(1, false), e.Action); return true; case GlobalAction.SelectPrevious: - beginRepeatSelection(() => SelectNext(-1, false), action); + beginRepeatSelection(() => SelectNext(-1, false), e.Action); return true; } return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.SelectNext: case GlobalAction.SelectPrevious: - endRepeatSelection(action); + endRepeatSelection(e.Action); break; } } @@ -720,7 +720,7 @@ namespace osu.Game.Screens.Select if (state.NewValue == CarouselItemState.Selected) { selectedBeatmapSet = set; - SelectionChanged?.Invoke(c.Beatmap); + SelectionChanged?.Invoke(c.BeatmapInfo); itemsCache.Invalidate(); ScrollToSelected(); diff --git a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs index b32416b361..4970db8955 100644 --- a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs +++ b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs @@ -17,9 +17,9 @@ namespace osu.Game.Screens.Select [Resolved] private ScoreManager scoreManager { get; set; } - public BeatmapClearScoresDialog(BeatmapInfo beatmap, Action onCompletion) + public BeatmapClearScoresDialog(BeatmapInfo beatmapInfo, Action onCompletion) { - BodyText = $@"{beatmap.Metadata?.Artist} - {beatmap.Metadata?.Title}"; + BodyText = $@"{beatmapInfo.Metadata?.Artist} - {beatmapInfo.Metadata?.Title}"; Icon = FontAwesome.Solid.Eraser; HeaderText = @"Clearing all local scores. Are you sure?"; Buttons = new PopupDialogButton[] @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Select Text = @"Yes. Please.", Action = () => { - Task.Run(() => scoreManager.Delete(scoreManager.QueryScores(s => !s.DeletePending && s.Beatmap.ID == beatmap.ID).ToList())) + Task.Run(() => scoreManager.Delete(scoreManager.QueryScores(s => !s.DeletePending && s.BeatmapInfo.ID == beatmapInfo.ID).ToList())) .ContinueWith(_ => onCompletion); } }, diff --git a/osu.Game/Screens/Select/BeatmapDetailArea.cs b/osu.Game/Screens/Select/BeatmapDetailArea.cs index 89ae92ec91..72c2ba708b 100644 --- a/osu.Game/Screens/Select/BeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/BeatmapDetailArea.cs @@ -22,7 +22,7 @@ namespace osu.Game.Screens.Select { beatmap = value; - Details.Beatmap = value?.BeatmapInfo; + Details.BeatmapInfo = value?.BeatmapInfo; } } diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 973f54c038..6ace92370c 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -41,16 +41,16 @@ namespace osu.Game.Screens.Select [Resolved] private RulesetStore rulesets { get; set; } - private BeatmapInfo beatmap; + private BeatmapInfo beatmapInfo; - public BeatmapInfo Beatmap + public BeatmapInfo BeatmapInfo { - get => beatmap; + get => beatmapInfo; set { - if (value == beatmap) return; + if (value == beatmapInfo) return; - beatmap = value; + beatmapInfo = value; Scheduler.AddOnce(updateStatistics); } @@ -170,26 +170,26 @@ namespace osu.Game.Screens.Select private void updateStatistics() { - advanced.Beatmap = Beatmap; - description.Text = Beatmap?.Version; - source.Text = Beatmap?.Metadata?.Source; - tags.Text = Beatmap?.Metadata?.Tags; + advanced.BeatmapInfo = BeatmapInfo; + description.Text = BeatmapInfo?.Version; + source.Text = BeatmapInfo?.Metadata?.Source; + tags.Text = BeatmapInfo?.Metadata?.Tags; // metrics may have been previously fetched - if (Beatmap?.BeatmapSet?.Metrics != null && Beatmap?.Metrics != null) + if (BeatmapInfo?.BeatmapSet?.Metrics != null && BeatmapInfo?.Metrics != null) { updateMetrics(); return; } // for now, let's early abort if an OnlineBeatmapID is not present (should have been populated at import time). - if (Beatmap?.OnlineBeatmapID == null || api.State.Value == APIState.Offline) + if (BeatmapInfo?.OnlineBeatmapID == null || api.State.Value == APIState.Offline) { updateMetrics(); return; } - var requestedBeatmap = Beatmap; + var requestedBeatmap = BeatmapInfo; var lookup = new GetBeatmapRequest(requestedBeatmap); @@ -197,11 +197,11 @@ namespace osu.Game.Screens.Select { Schedule(() => { - if (beatmap != requestedBeatmap) + if (beatmapInfo != requestedBeatmap) // the beatmap has been changed since we started the lookup. return; - var b = res.ToBeatmap(rulesets); + var b = res.ToBeatmapInfo(rulesets); if (requestedBeatmap.BeatmapSet == null) requestedBeatmap.BeatmapSet = b.BeatmapSet; @@ -218,7 +218,7 @@ namespace osu.Game.Screens.Select { Schedule(() => { - if (beatmap != requestedBeatmap) + if (beatmapInfo != requestedBeatmap) // the beatmap has been changed since we started the lookup. return; @@ -232,12 +232,12 @@ namespace osu.Game.Screens.Select private void updateMetrics() { - var hasRatings = beatmap?.BeatmapSet?.Metrics?.Ratings?.Any() ?? false; - var hasRetriesFails = (beatmap?.Metrics?.Retries?.Any() ?? false) || (beatmap?.Metrics?.Fails?.Any() ?? false); + var hasRatings = beatmapInfo?.BeatmapSet?.Metrics?.Ratings?.Any() ?? false; + var hasRetriesFails = (beatmapInfo?.Metrics?.Retries?.Any() ?? false) || (beatmapInfo?.Metrics?.Fails?.Any() ?? false); if (hasRatings) { - ratings.Metrics = beatmap.BeatmapSet.Metrics; + ratings.Metrics = beatmapInfo.BeatmapSet.Metrics; ratings.FadeIn(transition_duration); } else @@ -249,7 +249,7 @@ namespace osu.Game.Screens.Select if (hasRetriesFails) { - failRetryGraph.Metrics = beatmap.Metrics; + failRetryGraph.Metrics = beatmapInfo.Metrics; failRetryContainer.FadeIn(transition_duration); } else diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 4a35202df2..ac191a38f2 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -24,31 +24,26 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; -using osu.Game.Screens.Ranking.Expanded; using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Select { public class BeatmapInfoWedge : VisibilityContainer { + public const float BORDER_THICKNESS = 2.5f; private const float shear_width = 36.75f; + private const float transition_duration = 250; + private static readonly Vector2 wedged_container_shear = new Vector2(shear_width / SongSelect.WEDGE_HEIGHT, 0); [Resolved] private IBindable ruleset { get; set; } - [Resolved] - private IBindable> mods { get; set; } - - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } - - private IBindable beatmapDifficulty; - protected Container DisplayedContent { get; private set; } protected WedgeInfoText Info { get; private set; } @@ -58,7 +53,7 @@ namespace osu.Game.Screens.Select Shear = wedged_container_shear; Masking = true; BorderColour = new Color4(221, 255, 255, 255); - BorderThickness = 2.5f; + BorderThickness = BORDER_THICKNESS; Alpha = 0; EdgeEffect = new EdgeEffectParameters { @@ -75,24 +70,24 @@ namespace osu.Game.Screens.Select ruleset.BindValueChanged(_ => updateDisplay()); } + private const double animation_duration = 800; + protected override void PopIn() { - this.MoveToX(0, 800, Easing.OutQuint); - this.RotateTo(0, 800, Easing.OutQuint); - this.FadeIn(250); + this.MoveToX(0, animation_duration, Easing.OutQuint); + this.RotateTo(0, animation_duration, Easing.OutQuint); + this.FadeIn(transition_duration); } protected override void PopOut() { - this.MoveToX(-100, 800, Easing.In); - this.RotateTo(10, 800, Easing.In); - this.FadeOut(500, Easing.In); + this.MoveToX(-100, animation_duration, Easing.In); + this.RotateTo(10, animation_duration, Easing.In); + this.FadeOut(transition_duration * 2, Easing.In); } private WorkingBeatmap beatmap; - private CancellationTokenSource cancellationSource; - public WorkingBeatmap Beatmap { get => beatmap; @@ -101,12 +96,6 @@ namespace osu.Game.Screens.Select if (beatmap == value) return; beatmap = value; - cancellationSource?.Cancel(); - cancellationSource = new CancellationTokenSource(); - - beatmapDifficulty?.UnbindAll(); - beatmapDifficulty = difficultyCache.GetBindableDifficulty(beatmap.BeatmapInfo, cancellationSource.Token); - beatmapDifficulty.BindValueChanged(_ => updateDisplay()); updateDisplay(); } @@ -126,7 +115,7 @@ namespace osu.Game.Screens.Select { State.Value = beatmap == null ? Visibility.Hidden : Visibility.Visible; - DisplayedContent?.FadeOut(250); + DisplayedContent?.FadeOut(transition_duration); DisplayedContent?.Expire(); DisplayedContent = null; } @@ -145,7 +134,7 @@ namespace osu.Game.Screens.Select Children = new Drawable[] { new BeatmapInfoWedgeBackground(beatmap), - Info = new WedgeInfoText(beatmap, ruleset.Value, mods.Value, beatmapDifficulty.Value ?? new StarDifficulty()), + Info = new WedgeInfoText(beatmap, ruleset.Value), } }, loaded => { @@ -158,12 +147,6 @@ namespace osu.Game.Screens.Select } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - cancellationSource?.Cancel(); - } - public class WedgeInfoText : Container { public OsuSpriteText VersionLabel { get; private set; } @@ -172,6 +155,9 @@ namespace osu.Game.Screens.Select public BeatmapSetOnlineStatusPill StatusPill { get; private set; } public FillFlowContainer MapperContainer { get; private set; } + private Container difficultyColourBar; + private StarRatingDisplay starRatingDisplay; + private ILocalisedBindableString titleBinding; private ILocalisedBindableString artistBinding; private FillFlowContainer infoLabelContainer; @@ -179,21 +165,23 @@ namespace osu.Game.Screens.Select private readonly WorkingBeatmap beatmap; private readonly RulesetInfo ruleset; - private readonly IReadOnlyList mods; - private readonly StarDifficulty starDifficulty; + + [Resolved] + private IBindable> mods { get; set; } private ModSettingChangeTracker settingChangeTracker; - public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset, IReadOnlyList mods, StarDifficulty difficulty) + public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset) { this.beatmap = beatmap; ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset; - this.mods = mods; - starDifficulty = difficulty; } + private CancellationTokenSource cancellationSource; + private IBindable starDifficulty; + [BackgroundDependencyLoader] - private void load(LocalisationManager localisation) + private void load(OsuColour colours, LocalisationManager localisation, BeatmapDifficultyCache difficultyCache) { var beatmapInfo = beatmap.BeatmapInfo; var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); @@ -203,12 +191,30 @@ namespace osu.Game.Screens.Select titleBinding = localisation.GetLocalisedString(new RomanisableString(metadata.TitleUnicode, metadata.Title)); artistBinding = localisation.GetLocalisedString(new RomanisableString(metadata.ArtistUnicode, metadata.Artist)); + const float top_height = 0.7f; + Children = new Drawable[] { - new DifficultyColourBar(starDifficulty) + difficultyColourBar = new Container { RelativeSizeAxes = Axes.Y, - Width = 20, + Width = 20f, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Width = top_height, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Alpha = 0.5f, + X = top_height, + Width = 1 - top_height, + } + } }, new FillFlowContainer { @@ -239,14 +245,16 @@ namespace osu.Game.Screens.Select Padding = new MarginPadding { Top = 14, Right = shear_width / 2 }, AutoSizeAxes = Axes.Both, Shear = wedged_container_shear, - Children = new[] + Spacing = new Vector2(0f, 5f), + Children = new Drawable[] { - createStarRatingDisplay(starDifficulty).With(display => + starRatingDisplay = new StarRatingDisplay(default, animated: true) { - display.Anchor = Anchor.TopRight; - display.Origin = Anchor.TopRight; - display.Shear = -wedged_container_shear; - }), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Shear = -wedged_container_shear, + Alpha = 0f, + }, StatusPill = new BeatmapSetOnlineStatusPill { Anchor = Anchor.TopRight, @@ -302,6 +310,18 @@ namespace osu.Game.Screens.Select titleBinding.BindValueChanged(_ => setMetadata(metadata.Source)); artistBinding.BindValueChanged(_ => setMetadata(metadata.Source), true); + starRatingDisplay.DisplayedStars.BindValueChanged(s => + { + difficultyColourBar.Colour = colours.ForStarDifficulty(s.NewValue); + }, true); + + starDifficulty = difficultyCache.GetBindableDifficulty(beatmapInfo, (cancellationSource = new CancellationTokenSource()).Token); + starDifficulty.BindValueChanged(s => + { + starRatingDisplay.FadeIn(transition_duration); + starRatingDisplay.Current.Value = s.NewValue ?? default; + }); + // no difficulty means it can't have a status to show if (beatmapInfo.Version == null) StatusPill.Hide(); @@ -309,13 +329,6 @@ namespace osu.Game.Screens.Select addInfoLabels(); } - private static Drawable createStarRatingDisplay(StarDifficulty difficulty) => difficulty.Stars > 0 - ? new StarRatingDisplay(difficulty) - { - Margin = new MarginPadding { Bottom = 5 } - } - : Empty(); - private void setMetadata(string source) { ArtistLabel.Text = artistBinding.Value; @@ -333,7 +346,7 @@ namespace osu.Game.Screens.Select { Name = "Length", CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), - Content = TimeSpan.FromMilliseconds(beatmap.BeatmapInfo.Length).ToString(@"m\:ss"), + Content = beatmap.BeatmapInfo.Length.ToFormattedDuration().ToString(), }), bpmLabelContainer = new Container { @@ -347,10 +360,15 @@ namespace osu.Game.Screens.Select } }; - settingChangeTracker = new ModSettingChangeTracker(mods); - settingChangeTracker.SettingChanged += _ => refreshBPMLabel(); + mods.BindValueChanged(m => + { + settingChangeTracker?.Dispose(); - refreshBPMLabel(); + refreshBPMLabel(); + + settingChangeTracker = new ModSettingChangeTracker(m.NewValue); + settingChangeTracker.SettingChanged += _ => refreshBPMLabel(); + }, true); } private InfoLabel[] getRulesetInfoLabels() @@ -388,7 +406,7 @@ namespace osu.Game.Screens.Select // this doesn't consider mods which apply variable rates, yet. double rate = 1; - foreach (var mod in mods.OfType()) + foreach (var mod in mods.Value.OfType()) rate = mod.ApplyToRate(0, rate); double bpmMax = b.ControlPointInfo.BPMMaximum * rate; @@ -427,14 +445,18 @@ namespace osu.Game.Screens.Select { base.Dispose(isDisposing); settingChangeTracker?.Dispose(); + cancellationSource?.Cancel(); } public class InfoLabel : Container, IHasTooltip { public LocalisableString TooltipText { get; } + internal BeatmapStatistic Statistic { get; } + public InfoLabel(BeatmapStatistic statistic) { + Statistic = statistic; TooltipText = statistic.Name; AutoSizeAxes = Axes.Both; @@ -487,43 +509,6 @@ namespace osu.Game.Screens.Select }; } } - - private class DifficultyColourBar : Container - { - private readonly StarDifficulty difficulty; - - public DifficultyColourBar(StarDifficulty difficulty) - { - this.difficulty = difficulty; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - const float full_opacity_ratio = 0.7f; - - var difficultyColour = colours.ForDifficultyRating(difficulty.DifficultyRating); - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = difficultyColour, - Width = full_opacity_ratio, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Colour = difficultyColour, - Alpha = 0.5f, - X = full_opacity_ratio, - Width = 1 - full_opacity_ratio, - } - }; - } - } } } } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index f95ddfee41..d8c5aa760e 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -12,11 +12,11 @@ namespace osu.Game.Screens.Select.Carousel { public override float TotalHeight => DrawableCarouselBeatmap.HEIGHT; - public readonly BeatmapInfo Beatmap; + public readonly BeatmapInfo BeatmapInfo; - public CarouselBeatmap(BeatmapInfo beatmap) + public CarouselBeatmap(BeatmapInfo beatmapInfo) { - Beatmap = beatmap; + BeatmapInfo = beatmapInfo; State.Value = CarouselItemState.Collapsed; } @@ -28,36 +28,36 @@ namespace osu.Game.Screens.Select.Carousel bool match = criteria.Ruleset == null || - Beatmap.RulesetID == criteria.Ruleset.ID || - (Beatmap.RulesetID == 0 && criteria.Ruleset.ID > 0 && criteria.AllowConvertedBeatmaps); + BeatmapInfo.RulesetID == criteria.Ruleset.ID || + (BeatmapInfo.RulesetID == 0 && criteria.Ruleset.ID > 0 && criteria.AllowConvertedBeatmaps); - if (Beatmap.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) + if (BeatmapInfo.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) { // only check ruleset equality or convertability for selected beatmap Filtered.Value = !match; return; } - match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(Beatmap.StarDifficulty); - match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(Beatmap.BaseDifficulty.ApproachRate); - match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(Beatmap.BaseDifficulty.DrainRate); - match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(Beatmap.BaseDifficulty.CircleSize); - match &= !criteria.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); + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(BeatmapInfo.StarDifficulty); + match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(BeatmapInfo.BaseDifficulty.ApproachRate); + match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(BeatmapInfo.BaseDifficulty.DrainRate); + match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(BeatmapInfo.BaseDifficulty.CircleSize); + match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(BeatmapInfo.BaseDifficulty.OverallDifficulty); + match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(BeatmapInfo.Length); + match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(BeatmapInfo.BPM); - match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(Beatmap.BeatDivisor); - match &= !criteria.OnlineStatus.HasFilter || criteria.OnlineStatus.IsInRange(Beatmap.Status); + match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(BeatmapInfo.BeatDivisor); + match &= !criteria.OnlineStatus.HasFilter || criteria.OnlineStatus.IsInRange(BeatmapInfo.Status); - match &= !criteria.Creator.HasFilter || criteria.Creator.Matches(Beatmap.Metadata.AuthorString); - match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(Beatmap.Metadata.Artist) || - criteria.Artist.Matches(Beatmap.Metadata.ArtistUnicode); + match &= !criteria.Creator.HasFilter || criteria.Creator.Matches(BeatmapInfo.Metadata.AuthorString); + match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) || + criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); - match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(Beatmap.StarDifficulty); + match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarDifficulty); if (match) { - var terms = Beatmap.SearchableTerms; + var terms = BeatmapInfo.GetSearchableTerms(); foreach (var criteriaTerm in criteria.SearchTerms) match &= terms.Any(term => term.Contains(criteriaTerm, StringComparison.InvariantCultureIgnoreCase)); @@ -66,16 +66,16 @@ namespace osu.Game.Screens.Select.Carousel // this should be done after text matching so we can prioritise matching numbers in metadata. if (!match && criteria.SearchNumber.HasValue) { - match = (Beatmap.OnlineBeatmapID == criteria.SearchNumber.Value) || - (Beatmap.BeatmapSet?.OnlineBeatmapSetID == criteria.SearchNumber.Value); + match = (BeatmapInfo.OnlineBeatmapID == criteria.SearchNumber.Value) || + (BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID == criteria.SearchNumber.Value); } } if (match) - match &= criteria.Collection?.Beatmaps.Contains(Beatmap) ?? true; + match &= criteria.Collection?.Beatmaps.Contains(BeatmapInfo) ?? true; if (match && criteria.RulesetCriteria != null) - match &= criteria.RulesetCriteria.Matches(Beatmap); + match &= criteria.RulesetCriteria.Matches(BeatmapInfo); Filtered.Value = !match; } @@ -89,13 +89,13 @@ namespace osu.Game.Screens.Select.Carousel { default: case SortMode.Difficulty: - var ruleset = Beatmap.RulesetID.CompareTo(otherBeatmap.Beatmap.RulesetID); + var ruleset = BeatmapInfo.RulesetID.CompareTo(otherBeatmap.BeatmapInfo.RulesetID); if (ruleset != 0) return ruleset; - return Beatmap.StarDifficulty.CompareTo(otherBeatmap.Beatmap.StarDifficulty); + return BeatmapInfo.StarDifficulty.CompareTo(otherBeatmap.BeatmapInfo.StarDifficulty); } } - public override string ToString() => Beatmap.ToString(); + public override string ToString() => BeatmapInfo.ToString(); } } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index 00c2c2cb4a..0d7882bf17 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -47,8 +47,8 @@ namespace osu.Game.Screens.Select.Carousel { if (LastSelected == null || LastSelected.Filtered.Value) { - if (GetRecommendedBeatmap?.Invoke(Children.OfType().Where(b => !b.Filtered.Value).Select(b => b.Beatmap)) is BeatmapInfo recommended) - return Children.OfType().First(b => b.Beatmap == recommended); + if (GetRecommendedBeatmap?.Invoke(Children.OfType().Where(b => !b.Filtered.Value).Select(b => b.BeatmapInfo)) is BeatmapInfo recommended) + return Children.OfType().First(b => b.BeatmapInfo == recommended); } return base.GetNextToSelect(); @@ -91,7 +91,7 @@ namespace osu.Game.Screens.Select.Carousel /// /// All beatmaps which are not filtered and valid for display. /// - protected IEnumerable ValidBeatmaps => Beatmaps.Where(b => !b.Filtered.Value || b.State.Value == CarouselItemState.Selected).Select(b => b.Beatmap); + protected IEnumerable ValidBeatmaps => Beatmaps.Where(b => !b.Filtered.Value || b.State.Value == CarouselItemState.Selected).Select(b => b.BeatmapInfo); private int compareUsingAggregateMax(CarouselBeatmapSet other, Func func) { diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 633ef9297e..5940911d4a 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -40,7 +40,7 @@ namespace osu.Game.Screens.Select.Carousel private const float height = MAX_HEIGHT * 0.6f; - private readonly BeatmapInfo beatmap; + private readonly BeatmapInfo beatmapInfo; private Sprite background; @@ -68,7 +68,7 @@ namespace osu.Game.Screens.Select.Carousel public DrawableCarouselBeatmap(CarouselBeatmap panel) { - beatmap = panel.Beatmap; + beatmapInfo = panel.BeatmapInfo; Item = panel; } @@ -109,7 +109,7 @@ namespace osu.Game.Screens.Select.Carousel Origin = Anchor.CentreLeft, Children = new Drawable[] { - new DifficultyIcon(beatmap, shouldShowTooltip: false) + new DifficultyIcon(beatmapInfo, shouldShowTooltip: false) { Scale = new Vector2(1.8f), }, @@ -129,7 +129,7 @@ namespace osu.Game.Screens.Select.Carousel { new OsuSpriteText { - Text = beatmap.Version, + Text = beatmapInfo.Version, Font = OsuFont.GetFont(size: 20), Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft @@ -142,7 +142,7 @@ namespace osu.Game.Screens.Select.Carousel }, new OsuSpriteText { - Text = $"{(beatmap.Metadata ?? beatmap.BeatmapSet.Metadata).Author.Username}", + Text = $"{(beatmapInfo.Metadata ?? beatmapInfo.BeatmapSet.Metadata).Author.Username}", Font = OsuFont.GetFont(italics: true), Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft @@ -156,7 +156,7 @@ namespace osu.Game.Screens.Select.Carousel AutoSizeAxes = Axes.Both, Children = new Drawable[] { - new TopLocalRank(beatmap) + new TopLocalRank(beatmapInfo) { Scale = new Vector2(0.8f), Size = new Vector2(40, 20) @@ -200,7 +200,7 @@ namespace osu.Game.Screens.Select.Carousel protected override bool OnClick(ClickEvent e) { if (Item.State.Value == CarouselItemState.Selected) - startRequested?.Invoke(beatmap); + startRequested?.Invoke(beatmapInfo); return base.OnClick(e); } @@ -216,7 +216,7 @@ namespace osu.Game.Screens.Select.Carousel if (Item.State.Value != CarouselItemState.Collapsed) { // We've potentially cancelled the computation above so a new bindable is required. - starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, (starDifficultyCancellationSource = new CancellationTokenSource()).Token); + starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmapInfo, (starDifficultyCancellationSource = new CancellationTokenSource()).Token); starDifficultyBindable.BindValueChanged(d => { starCounter.Current = (float)(d.NewValue?.Stars ?? 0); @@ -233,13 +233,13 @@ namespace osu.Game.Screens.Select.Carousel List items = new List(); if (startRequested != null) - items.Add(new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested(beatmap))); + items.Add(new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested(beatmapInfo))); if (editRequested != null) - items.Add(new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested(beatmap))); + items.Add(new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested(beatmapInfo))); - if (beatmap.OnlineBeatmapID.HasValue && beatmapOverlay != null) - items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value))); + if (beatmapInfo.OnlineBeatmapID.HasValue && beatmapOverlay != null) + items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmapInfo.OnlineBeatmapID.Value))); if (collectionManager != null) { @@ -251,7 +251,7 @@ namespace osu.Game.Screens.Select.Carousel } if (hideRequested != null) - items.Add(new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested(beatmap))); + items.Add(new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested(beatmapInfo))); return items.ToArray(); } @@ -262,12 +262,12 @@ namespace osu.Game.Screens.Select.Carousel return new ToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => { if (s) - collection.Beatmaps.Add(beatmap); + collection.Beatmaps.Add(beatmapInfo); else - collection.Beatmaps.Remove(beatmap); + collection.Beatmaps.Remove(beatmapInfo); }) { - State = { Value = collection.Beatmaps.Contains(beatmap) } + State = { Value = collection.Beatmaps.Contains(beatmapInfo) } }; } diff --git a/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs b/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs index 51fe7796c7..ce0cec837b 100644 --- a/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs +++ b/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Select.Carousel public readonly CarouselBeatmap Item; public FilterableDifficultyIcon(CarouselBeatmap item) - : base(item.Beatmap, performBackgroundDifficultyLookup: false) + : base(item.BeatmapInfo, performBackgroundDifficultyLookup: false) { filtered.BindTo(item.Filtered); filtered.ValueChanged += isFiltered => Schedule(() => this.FadeTo(isFiltered.NewValue ? 0.1f : 1, 100)); diff --git a/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs b/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs index d2f9ed3a6a..acffdd9f64 100644 --- a/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs +++ b/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.Select.Carousel public readonly List Items; public FilterableGroupedDifficultyIcon(List items, RulesetInfo ruleset) - : base(items.Select(i => i.Beatmap).ToList(), ruleset, Color4.White) + : base(items.Select(i => i.BeatmapInfo).ToList(), ruleset, Color4.White) { Items = items; diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs index 23a02547b2..9fb640ba1a 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs @@ -86,7 +86,7 @@ namespace osu.Game.Screens.Select.Carousel var beatmaps = carouselSet.Beatmaps.ToList(); return beatmaps.Count > maximum_difficulty_icons - ? (IEnumerable)beatmaps.GroupBy(b => b.Beatmap.Ruleset).Select(group => new FilterableGroupedDifficultyIcon(group.ToList(), group.Key)) + ? (IEnumerable)beatmaps.GroupBy(b => b.BeatmapInfo.Ruleset).Select(group => new FilterableGroupedDifficultyIcon(group.ToList(), group.Key)) : beatmaps.Select(b => new FilterableDifficultyIcon(b)); } } diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs index 3ad57c1cb0..f2485587d8 100644 --- a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs +++ b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Select.Carousel { public class TopLocalRank : UpdateableRank { - private readonly BeatmapInfo beatmap; + private readonly BeatmapInfo beatmapInfo; [Resolved] private ScoreManager scores { get; set; } @@ -31,10 +31,10 @@ namespace osu.Game.Screens.Select.Carousel private IBindable> itemUpdated; private IBindable> itemRemoved; - public TopLocalRank(BeatmapInfo beatmap) + public TopLocalRank(BeatmapInfo beatmapInfo) : base(null) { - this.beatmap = beatmap; + this.beatmapInfo = beatmapInfo; } [BackgroundDependencyLoader] @@ -55,7 +55,7 @@ namespace osu.Game.Screens.Select.Carousel { if (weakScore.NewValue.TryGetTarget(out var score)) { - if (score.BeatmapInfoID == beatmap.ID) + if (score.BeatmapInfoID == beatmapInfo.ID) fetchAndLoadTopScore(); } } @@ -79,10 +79,10 @@ namespace osu.Game.Screens.Select.Carousel private ScoreInfo fetchTopScore() { - if (scores == null || beatmap == null || ruleset?.Value == null || api?.LocalUser.Value == null) + if (scores == null || beatmapInfo == null || ruleset?.Value == null || api?.LocalUser.Value == null) return null; - return scores.QueryScores(s => s.UserID == api.LocalUser.Value.Id && s.BeatmapInfoID == beatmap.ID && s.RulesetID == ruleset.Value.ID && !s.DeletePending) + return scores.QueryScores(s => s.UserID == api.LocalUser.Value.Id && s.BeatmapInfoID == beatmapInfo.ID && s.RulesetID == ruleset.Value.ID && !s.DeletePending) .OrderByDescending(s => s.TotalScore) .FirstOrDefault(); } diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index 53e30fd9ca..562ebad9fe 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -38,16 +38,16 @@ namespace osu.Game.Screens.Select.Details protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; private readonly StatisticRow starDifficulty; - private BeatmapInfo beatmap; + private BeatmapInfo beatmapInfo; - public BeatmapInfo Beatmap + public BeatmapInfo BeatmapInfo { - get => beatmap; + get => beatmapInfo; set { - if (value == beatmap) return; + if (value == beatmapInfo) return; - beatmap = value; + beatmapInfo = value; updateStatistics(); } @@ -106,18 +106,18 @@ namespace osu.Game.Screens.Select.Details private void updateStatistics() { - BeatmapDifficulty baseDifficulty = Beatmap?.BaseDifficulty; + IBeatmapDifficultyInfo baseDifficulty = BeatmapInfo?.BaseDifficulty; BeatmapDifficulty adjustedDifficulty = null; if (baseDifficulty != null && mods.Value.Any(m => m is IApplicableToDifficulty)) { - adjustedDifficulty = baseDifficulty.Clone(); + adjustedDifficulty = new BeatmapDifficulty(baseDifficulty); foreach (var mod in mods.Value.OfType()) mod.ApplyToDifficulty(adjustedDifficulty); } - switch (Beatmap?.Ruleset?.ID ?? 0) + switch (BeatmapInfo?.Ruleset?.ID ?? 0) { case 3: // Account for mania differences locally for now @@ -145,13 +145,13 @@ namespace osu.Game.Screens.Select.Details { starDifficultyCancellationSource?.Cancel(); - if (Beatmap == null) + if (BeatmapInfo == null) return; starDifficultyCancellationSource = new CancellationTokenSource(); - var normalStarDifficulty = difficultyCache.GetDifficultyAsync(Beatmap, ruleset.Value, null, starDifficultyCancellationSource.Token); - var moddedStarDifficulty = difficultyCache.GetDifficultyAsync(Beatmap, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + var normalStarDifficulty = difficultyCache.GetDifficultyAsync(BeatmapInfo, ruleset.Value, null, starDifficultyCancellationSource.Token); + var moddedStarDifficulty = difficultyCache.GetDifficultyAsync(BeatmapInfo, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); Task.WhenAll(normalStarDifficulty, moddedStarDifficulty).ContinueWith(_ => Schedule(() => { diff --git a/osu.Game/Screens/Select/Details/UserRatings.cs b/osu.Game/Screens/Select/Details/UserRatings.cs index cf5e3ba1b3..eabc476db9 100644 --- a/osu.Game/Screens/Select/Details/UserRatings.cs +++ b/osu.Game/Screens/Select/Details/UserRatings.cs @@ -8,7 +8,9 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using System.Linq; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Game.Beatmaps; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Screens.Select.Details { @@ -35,8 +37,8 @@ namespace osu.Game.Screens.Select.Details if (metrics == null) { - negativeRatings.Text = "0"; - positiveRatings.Text = "0"; + negativeRatings.Text = 0.ToLocalisableString(@"N0"); + positiveRatings.Text = 0.ToLocalisableString(@"N0"); ratingsBar.Length = 0; graph.Values = new float[rating_range]; } @@ -47,8 +49,8 @@ namespace osu.Game.Screens.Select.Details var negativeCount = ratings.Take(rating_range / 2).Sum(); var totalCount = ratings.Sum(); - negativeRatings.Text = negativeCount.ToString(); - positiveRatings.Text = (totalCount - negativeCount).ToString(); + negativeRatings.Text = negativeCount.ToLocalisableString(@"N0"); + positiveRatings.Text = (totalCount - negativeCount).ToLocalisableString(@"N0"); ratingsBar.Length = totalCount == 0 ? 0 : (float)negativeCount / totalCount; graph.Values = ratings.Take(rating_range).Select(r => (float)r); } @@ -70,7 +72,7 @@ namespace osu.Game.Screens.Select.Details { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "User Rating", + Text = BeatmapsetsStrings.ShowStatsUserRating, Font = OsuFont.GetFont(size: 12), Margin = new MarginPadding { Bottom = 5 }, }, @@ -88,14 +90,14 @@ namespace osu.Game.Screens.Select.Details { negativeRatings = new OsuSpriteText { - Text = "0", + Text = 0.ToLocalisableString(@"N0"), Font = OsuFont.GetFont(size: 12) }, positiveRatings = new OsuSpriteText { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Text = @"0", + Text = 0.ToLocalisableString(@"N0"), Font = OsuFont.GetFont(size: 12) }, }, @@ -104,7 +106,7 @@ namespace osu.Game.Screens.Select.Details { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "Rating Spread", + Text = BeatmapsetsStrings.ShowStatsRatingSpread, Font = OsuFont.GetFont(size: 12), Margin = new MarginPadding { Bottom = 5 }, }, diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index b9e912df8e..f47bc5f466 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -56,7 +56,7 @@ namespace osu.Game.Screens.Select set { searchText = value; - SearchTerms = searchText.Split(new[] { ',', ' ', '!' }, StringSplitOptions.RemoveEmptyEntries).ToArray(); + SearchTerms = searchText.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToArray(); SearchNumber = null; diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 72d10019b2..a882148392 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -3,8 +3,8 @@ using System; using System.Globalization; +using System.Linq; using System.Text.RegularExpressions; -using osu.Game.Beatmaps; using osu.Game.Screens.Select.Filter; namespace osu.Game.Screens.Select @@ -64,8 +64,7 @@ namespace osu.Game.Screens.Select return TryUpdateCriteriaRange(ref criteria.BeatDivisor, op, value, tryParseInt); case "status": - return TryUpdateCriteriaRange(ref criteria.OnlineStatus, op, value, - (string s, out BeatmapSetOnlineStatus val) => Enum.TryParse(value, true, out val)); + return TryUpdateCriteriaRange(ref criteria.OnlineStatus, op, value, tryParseEnum); case "creator": return TryUpdateCriteriaText(ref criteria.Creator, op, value); @@ -120,6 +119,14 @@ namespace osu.Game.Screens.Select private static bool tryParseInt(string value, out int result) => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result); + private static bool tryParseEnum(string value, out TEnum result) where TEnum : struct + { + if (Enum.TryParse(value, true, out result)) return true; + + value = Enum.GetNames(typeof(TEnum)).FirstOrDefault(name => name.StartsWith(value, true, CultureInfo.InvariantCulture)); + return Enum.TryParse(value, true, out result); + } + /// /// Attempts to parse a keyword filter with the specified and textual . /// If the value indicates a valid textual filter, the function returns true and the resulting data is stored into diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index c3fbd767ff..8d2ea47757 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -172,17 +172,17 @@ namespace osu.Game.Screens.Select return base.OnClick(e); } - public virtual bool OnPressed(GlobalAction action) + public virtual bool OnPressed(KeyBindingPressEvent e) { - if (action == Hotkey) + if (e.Action == Hotkey) { - Click(); + TriggerClick(); return true; } return false; } - public virtual void OnReleased(GlobalAction action) { } + public virtual void OnReleased(KeyBindingReleaseEvent e) { } } } diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index 2d14111137..1d4722cf5d 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; @@ -58,22 +59,22 @@ namespace osu.Game.Screens.Select }; } - public override bool OnPressed(GlobalAction action) + public override bool OnPressed(KeyBindingPressEvent e) { - rewindSearch = action == GlobalAction.SelectPreviousRandom; + rewindSearch = e.Action == GlobalAction.SelectPreviousRandom; - if (action != GlobalAction.SelectNextRandom && action != GlobalAction.SelectPreviousRandom) + if (e.Action != GlobalAction.SelectNextRandom && e.Action != GlobalAction.SelectPreviousRandom) { return false; } - Click(); + TriggerClick(); return true; } - public override void OnReleased(GlobalAction action) + public override void OnReleased(KeyBindingReleaseEvent e) { - if (action == GlobalAction.SelectPreviousRandom) + if (e.Action == GlobalAction.SelectPreviousRandom) { rewindSearch = false; } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index a86a614a05..07300635aa 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -23,17 +25,17 @@ namespace osu.Game.Screens.Select.Leaderboards [Resolved] private RulesetStore rulesets { get; set; } - private BeatmapInfo beatmap; + private BeatmapInfo beatmapInfo; - public BeatmapInfo Beatmap + public BeatmapInfo BeatmapInfo { - get => beatmap; + get => beatmapInfo; set { - if (beatmap == value) + if (beatmapInfo == value) return; - beatmap = value; + beatmapInfo = value; Scores = null; UpdateScores(); @@ -66,6 +68,9 @@ namespace osu.Game.Screens.Select.Leaderboards [Resolved] private ScoreManager scoreManager { get; set; } + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + [Resolved] private IBindable ruleset { get; set; } @@ -111,7 +116,7 @@ namespace osu.Game.Screens.Select.Leaderboards if (score.NewValue.TryGetTarget(out var scoreInfo)) { - if (Beatmap?.ID != scoreInfo.BeatmapInfoID) + if (BeatmapInfo?.ID != scoreInfo.BeatmapInfoID) return; } @@ -120,9 +125,14 @@ namespace osu.Game.Screens.Select.Leaderboards protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; + private CancellationTokenSource loadCancellationSource; + protected override APIRequest FetchScores(Action> scoresCallback) { - if (Beatmap == null) + loadCancellationSource?.Cancel(); + loadCancellationSource = new CancellationTokenSource(); + + if (BeatmapInfo == null) { PlaceholderState = PlaceholderState.NoneSelected; return null; @@ -131,7 +141,7 @@ namespace osu.Game.Screens.Select.Leaderboards if (Scope == BeatmapLeaderboardScope.Local) { var scores = scoreManager - .QueryScores(s => !s.DeletePending && s.Beatmap.ID == Beatmap.ID && s.Ruleset.ID == ruleset.Value.ID); + .QueryScores(s => !s.DeletePending && s.BeatmapInfo.ID == BeatmapInfo.ID && s.Ruleset.ID == ruleset.Value.ID); if (filterMods && !mods.Value.Any()) { @@ -146,8 +156,8 @@ namespace osu.Game.Screens.Select.Leaderboards scores = scores.Where(s => s.Mods.Any(m => selectedMods.Contains(m.Acronym))); } - Scores = scores.OrderByDescending(s => s.TotalScore).ToArray(); - PlaceholderState = Scores.Any() ? PlaceholderState.Successful : PlaceholderState.NoScores; + scoreManager.OrderByTotalScoreAsync(scores.ToArray(), loadCancellationSource.Token) + .ContinueWith(ordered => scoresCallback?.Invoke(ordered.Result), TaskContinuationOptions.OnlyOnRanToCompletion); return null; } @@ -158,7 +168,7 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } - if (Beatmap.OnlineBeatmapID == null || Beatmap?.Status <= BeatmapSetOnlineStatus.Pending) + if (BeatmapInfo.OnlineBeatmapID == null || BeatmapInfo?.Status <= BeatmapSetOnlineStatus.Pending) { PlaceholderState = PlaceholderState.Unavailable; return null; @@ -178,12 +188,19 @@ namespace osu.Game.Screens.Select.Leaderboards else if (filterMods) requestMods = mods.Value; - var req = new GetScoresRequest(Beatmap, ruleset.Value ?? Beatmap.Ruleset, Scope, requestMods); + var req = new GetScoresRequest(BeatmapInfo, ruleset.Value ?? BeatmapInfo.Ruleset, Scope, requestMods); req.Success += r => { - scoresCallback?.Invoke(r.Scores.Select(s => s.CreateScoreInfo(rulesets))); - TopScore = r.UserScore?.CreateScoreInfo(rulesets); + scoreManager.OrderByTotalScoreAsync(r.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToArray(), loadCancellationSource.Token) + .ContinueWith(ordered => Schedule(() => + { + if (loadCancellationSource.IsCancellationRequested) + return; + + scoresCallback?.Invoke(ordered.Result); + TopScore = r.UserScore?.CreateScoreInfo(rulesets); + }), TaskContinuationOptions.OnlyOnRanToCompletion); }; return req; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs index dc4c2ba4e2..5bcb4c27a7 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Screens.Select.Leaderboards { @@ -10,13 +12,13 @@ namespace osu.Game.Screens.Select.Leaderboards [Description("Local Ranking")] Local, - [Description("Country Ranking")] + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardCountry))] Country, - [Description("Global Ranking")] + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardGlobal))] Global, - [Description("Friend Ranking")] + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowScoreboardFriend))] Friend, } } diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs index 085ea372c0..1ae244281b 100644 --- a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs @@ -29,8 +29,8 @@ namespace osu.Game.Screens.Select [BackgroundDependencyLoader] private void load() { - BeatmapInfo beatmap = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); - Debug.Assert(beatmap != null); + BeatmapInfo beatmapInfo = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); + Debug.Assert(beatmapInfo != null); BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index 845c0a914e..6ecb96f723 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Select.Options { @@ -76,6 +77,7 @@ namespace osu.Game.Screens.Select.Options public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); public BeatmapOptionsButton() + : base(HoverSampleSet.Submit) { Width = width; RelativeSizeAxes = Axes.Y; diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs index 2676635764..b5fdbd225f 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs @@ -122,7 +122,7 @@ namespace osu.Game.Screens.Select.Options if (found != null) { - found.Click(); + found.TriggerClick(); return true; } } diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs index c87a4bbc54..b8b8e3e4bc 100644 --- a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -23,7 +23,7 @@ namespace osu.Game.Screens.Select { base.Beatmap = value; - Leaderboard.Beatmap = value is DummyWorkingBeatmap ? null : value?.BeatmapInfo; + Leaderboard.BeatmapInfo = value is DummyWorkingBeatmap ? null : value?.BeatmapInfo; } } diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 418cf23ce7..94aa165785 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Select } protected void PresentScore(ScoreInfo score) => - FinaliseSelection(score.Beatmap, score.Ruleset, () => this.Push(new SoloResultsScreen(score, false))); + FinaliseSelection(score.BeatmapInfo, score.Ruleset, () => this.Push(new SoloResultsScreen(score, false))); protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 270addc8e6..6cafcb9d16 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -53,6 +53,8 @@ namespace osu.Game.Screens.Select protected virtual bool DisplayStableImportPrompt => stableImportManager?.SupportsImportFromStable == true; + public override bool? AllowTrackAdjustments => true; + /// /// Can be null if is false. /// @@ -79,6 +81,8 @@ namespace osu.Game.Screens.Select protected BeatmapCarousel Carousel { get; private set; } + protected Container LeftArea { get; private set; } + private BeatmapInfoWedge beatmapInfoWedge; private DialogOverlay dialogOverlay; @@ -186,12 +190,12 @@ namespace osu.Game.Screens.Select { new Drawable[] { - new Container + LeftArea = new Container { Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, RelativeSizeAxes = Axes.Both, - + Padding = new MarginPadding { Top = left_area_padding }, Children = new Drawable[] { beatmapInfoWedge = new BeatmapInfoWedge @@ -200,8 +204,8 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.X, Margin = new MarginPadding { - Top = left_area_padding, Right = left_area_padding, + Left = -BeatmapInfoWedge.BORDER_THICKNESS, // Hide the left border }, }, new Container @@ -210,7 +214,7 @@ namespace osu.Game.Screens.Select Padding = new MarginPadding { Bottom = Footer.HEIGHT, - Top = WEDGE_HEIGHT + left_area_padding, + Top = WEDGE_HEIGHT, Left = left_area_padding, Right = left_area_padding * 2, }, @@ -313,7 +317,7 @@ namespace osu.Game.Screens.Select (new FooterButtonOptions(), BeatmapOptions) }; - protected virtual ModSelectOverlay CreateModSelectOverlay() => new LocalPlayerModSelectOverlay(); + protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(); protected virtual void ApplyFilterToCarousel(FilterCriteria criteria) { @@ -341,22 +345,22 @@ namespace osu.Game.Screens.Select /// protected abstract BeatmapDetailArea CreateBeatmapDetailArea(); - public void Edit(BeatmapInfo beatmap = null) + public void Edit(BeatmapInfo beatmapInfo = null) { if (!AllowEditing) throw new InvalidOperationException($"Attempted to edit when {nameof(AllowEditing)} is disabled"); - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap ?? beatmapNoDebounce); - this.Push(new Editor()); + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo ?? beatmapInfoNoDebounce); + this.Push(new EditorLoader()); } /// /// Call to make a selection and perform the default action for this SongSelect. /// - /// An optional beatmap to override the current carousel selection. + /// An optional beatmap to override the current carousel selection. /// An optional ruleset to override the current carousel selection. /// An optional custom action to perform instead of . - public void FinaliseSelection(BeatmapInfo beatmap = null, RulesetInfo ruleset = null, Action customStartAction = null) + public void FinaliseSelection(BeatmapInfo beatmapInfo = null, RulesetInfo ruleset = null, Action customStartAction = null) { // This is very important as we have not yet bound to screen-level bindables before the carousel load is completed. if (!Carousel.BeatmapSetsLoaded) @@ -373,10 +377,10 @@ namespace osu.Game.Screens.Select // avoid attempting to continue before a selection has been obtained. // this could happen via a user interaction while the carousel is still in a loading state. - if (Carousel.SelectedBeatmap == null) return; + if (Carousel.SelectedBeatmapInfo == null) return; - if (beatmap != null) - Carousel.SelectBeatmap(beatmap); + if (beatmapInfo != null) + Carousel.SelectBeatmap(beatmapInfo); if (selectionChangedDebounce?.Completed == false) { @@ -431,18 +435,18 @@ namespace osu.Game.Screens.Select } // We need to keep track of the last selected beatmap ignoring debounce to play the correct selection sounds. - private BeatmapInfo beatmapNoDebounce; + private BeatmapInfo beatmapInfoNoDebounce; private RulesetInfo rulesetNoDebounce; - private void updateSelectedBeatmap(BeatmapInfo beatmap) + private void updateSelectedBeatmap(BeatmapInfo beatmapInfo) { - if (beatmap == null && beatmapNoDebounce == null) + if (beatmapInfo == null && beatmapInfoNoDebounce == null) return; - if (beatmap?.Equals(beatmapNoDebounce) == true) + if (beatmapInfo?.Equals(beatmapInfoNoDebounce) == true) return; - beatmapNoDebounce = beatmap; + beatmapInfoNoDebounce = beatmapInfo; performUpdateSelected(); } @@ -463,12 +467,12 @@ namespace osu.Game.Screens.Select /// private void performUpdateSelected() { - var beatmap = beatmapNoDebounce; + var beatmap = beatmapInfoNoDebounce; var ruleset = rulesetNoDebounce; selectionChangedDebounce?.Cancel(); - if (beatmapNoDebounce == null) + if (beatmapInfoNoDebounce == null) run(); else selectionChangedDebounce = Scheduler.AddDelayed(run, 200); @@ -799,20 +803,20 @@ namespace osu.Game.Screens.Select dialogOverlay?.Push(new BeatmapDeleteDialog(beatmap)); } - private void clearScores(BeatmapInfo beatmap) + private void clearScores(BeatmapInfo beatmapInfo) { - if (beatmap == null || beatmap.ID <= 0) return; + if (beatmapInfo == null || beatmapInfo.ID <= 0) return; - dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmap, () => + dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmapInfo, () => // schedule done here rather than inside the dialog as the dialog may fade out and never callback. Schedule(() => BeatmapDetails.Refresh()))); } - public virtual bool OnPressed(GlobalAction action) + public virtual bool OnPressed(KeyBindingPressEvent e) { if (!this.IsCurrentScreen()) return false; - switch (action) + switch (e.Action) { case GlobalAction.Select: FinaliseSelection(); @@ -822,7 +826,7 @@ namespace osu.Game.Screens.Select return false; } - public void OnReleased(GlobalAction action) + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Screens/Spectate/GameplayState.cs b/osu.Game/Screens/Spectate/SpectatorGameplayState.cs similarity index 81% rename from osu.Game/Screens/Spectate/GameplayState.cs rename to osu.Game/Screens/Spectate/SpectatorGameplayState.cs index 4579b9c07c..6ca1ac9a0a 100644 --- a/osu.Game/Screens/Spectate/GameplayState.cs +++ b/osu.Game/Screens/Spectate/SpectatorGameplayState.cs @@ -8,9 +8,9 @@ using osu.Game.Scoring; namespace osu.Game.Screens.Spectate { /// - /// The gameplay state of a spectated user. This class is immutable. + /// An immutable spectator gameplay state. /// - public class GameplayState + public class SpectatorGameplayState { /// /// The score which the user is playing. @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Spectate /// public readonly WorkingBeatmap Beatmap; - public GameplayState(Score score, Ruleset ruleset, WorkingBeatmap beatmap) + public SpectatorGameplayState(Score score, Ruleset ruleset, WorkingBeatmap beatmap) { Score = score; Ruleset = ruleset; diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index 8fc9222f59..7861d4cb72 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; @@ -25,9 +24,9 @@ namespace osu.Game.Screens.Spectate /// public abstract class SpectatorScreen : OsuScreen { - protected IReadOnlyList UserIds => userIds; + protected IReadOnlyList Users => users; - private readonly List userIds = new List(); + private readonly List users = new List(); [Resolved] private BeatmapManager beatmaps { get; set; } @@ -44,27 +43,32 @@ namespace osu.Game.Screens.Spectate private readonly IBindableDictionary playingUserStates = new BindableDictionary(); private readonly Dictionary userMap = new Dictionary(); - private readonly Dictionary gameplayStates = new Dictionary(); + private readonly Dictionary gameplayStates = new Dictionary(); private IBindable> managerUpdated; /// /// Creates a new . /// - /// The users to spectate. - protected SpectatorScreen(params int[] userIds) + /// The users to spectate. + protected SpectatorScreen(params int[] users) { - this.userIds.AddRange(userIds); + this.users.AddRange(users); } protected override void LoadComplete() { base.LoadComplete(); - getAllUsers().ContinueWith(users => Schedule(() => + userLookupCache.GetUsersAsync(users.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); @@ -77,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)) @@ -179,7 +165,7 @@ namespace osu.Game.Screens.Spectate { ScoreInfo = new ScoreInfo { - Beatmap = resolvedBeatmap, + BeatmapInfo = resolvedBeatmap, User = user, Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(), Ruleset = resolvedRuleset.RulesetInfo, @@ -187,7 +173,7 @@ namespace osu.Game.Screens.Spectate Replay = new Replay { HasReceivedAllFrames = false }, }; - var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap)); + var gameplayState = new SpectatorGameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap)); gameplayStates[userId] = gameplayState; Schedule(() => StartGameplay(userId, gameplayState)); @@ -204,8 +190,8 @@ namespace osu.Game.Screens.Spectate /// Starts gameplay for a user. /// /// The user to start gameplay for. - /// The gameplay state. - protected abstract void StartGameplay(int userId, [NotNull] GameplayState gameplayState); + /// The gameplay state. + protected abstract void StartGameplay(int userId, [NotNull] SpectatorGameplayState spectatorGameplayState); /// /// Ends gameplay for a user. @@ -221,7 +207,7 @@ namespace osu.Game.Screens.Spectate { onUserStateRemoved(userId); - userIds.Remove(userId); + users.Remove(userId); userMap.Remove(userId); spectatorClient.StopWatchingUser(userId); diff --git a/osu.Game/Screens/StartupScreen.cs b/osu.Game/Screens/StartupScreen.cs index e5e134fd39..be217d6b1f 100644 --- a/osu.Game/Screens/StartupScreen.cs +++ b/osu.Game/Screens/StartupScreen.cs @@ -16,8 +16,6 @@ namespace osu.Game.Screens public override bool CursorVisible => false; - public override bool AllowRateAdjustments => false; - protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled; } } diff --git a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs index f12f44e347..57c08a903f 100644 --- a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs +++ b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs @@ -83,9 +83,9 @@ namespace osu.Game.Skinning [BackgroundDependencyLoader] private void load() { - beatmapSkins.BindValueChanged(_ => OnSourceChanged()); - beatmapColours.BindValueChanged(_ => OnSourceChanged()); - beatmapHitsounds.BindValueChanged(_ => OnSourceChanged()); + beatmapSkins.BindValueChanged(_ => TriggerSourceChanged()); + beatmapColours.BindValueChanged(_ => TriggerSourceChanged()); + beatmapHitsounds.BindValueChanged(_ => TriggerSourceChanged()); } } } diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index 30192182f3..16ac17546d 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.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 JetBrains.Annotations; using osu.Framework.IO.Stores; using osu.Game.Extensions; @@ -21,12 +22,13 @@ namespace osu.Game.Skinning : base(skin, new NamespacedResourceStore(resources.Resources, "Skins/Legacy"), resources, string.Empty) { Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255); - Configuration.AddComboColours( + Configuration.CustomComboColours = new List + { new Color4(255, 192, 0, 255), new Color4(0, 202, 0, 255), new Color4(18, 124, 255, 255), new Color4(242, 24, 57, 255) - ); + }; Configuration.LegacyVersion = 2.7m; } diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 893819b2c2..8e03bddb4d 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; @@ -67,6 +68,7 @@ namespace osu.Game.Skinning var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); var combo = container.OfType().FirstOrDefault(); + var ppCounter = container.OfType().FirstOrDefault(); if (score != null) { @@ -80,6 +82,13 @@ namespace osu.Game.Skinning score.Position = new Vector2(0, vertical_offset); + if (ppCounter != null) + { + ppCounter.Y = score.Position.Y + ppCounter.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).Y - 4; + ppCounter.Origin = Anchor.TopCentre; + ppCounter.Anchor = Anchor.TopCentre; + } + if (accuracy != null) { accuracy.Position = new Vector2(-accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 - horizontal_padding, vertical_offset + 5); @@ -122,6 +131,7 @@ namespace osu.Game.Skinning new SongProgress(), new BarHitErrorMeter(), new BarHitErrorMeter(), + new PerformancePointsCounter() } }; @@ -136,10 +146,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) { @@ -148,9 +158,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/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 07a94cac7a..8052f82c93 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Skinning.Editor @@ -88,6 +89,13 @@ namespace osu.Game.Skinning.Editor Children = new Drawable[] { new SkinBlueprintContainer(targetScreen), + new TriangleButton + { + Margin = new MarginPadding(10), + Text = CommonStrings.ButtonsClose, + Width = 100, + Action = Hide, + }, new FillFlowContainer { Direction = FillDirection.Horizontal, diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index 88020896bb..d27122aea8 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; @@ -19,6 +22,8 @@ namespace osu.Game.Skinning.Editor public class SkinEditorOverlay : CompositeDrawable, IKeyBindingHandler { private readonly ScalingContainer target; + + [CanBeNull] private SkinEditor skinEditor; public const float VISIBLE_TARGET_SCALE = 0.8f; @@ -32,39 +37,60 @@ namespace osu.Game.Skinning.Editor RelativeSizeAxes = Axes.Both; } - public bool OnPressed(GlobalAction action) + public bool OnPressed(KeyBindingPressEvent e) { - switch (action) + switch (e.Action) { case GlobalAction.Back: - if (skinEditor?.State.Value == Visibility.Visible) - { - skinEditor.ToggleVisibility(); - return true; - } + if (skinEditor?.State.Value != Visibility.Visible) + break; - break; + Hide(); + return true; case GlobalAction.ToggleSkinEditor: - if (skinEditor == null) - { - LoadComponentAsync(skinEditor = new SkinEditor(target), AddInternal); - skinEditor.State.BindValueChanged(editorVisibilityChanged); - } - else - skinEditor.ToggleVisibility(); - + Toggle(); return true; } return false; } + public void Toggle() + { + if (skinEditor == null) + Show(); + else + skinEditor.ToggleVisibility(); + } + + public override void Hide() + { + // base call intentionally omitted. + skinEditor?.Hide(); + } + + public override void Show() + { + // base call intentionally omitted. + if (skinEditor == null) + { + skinEditor = new SkinEditor(target); + skinEditor.State.BindValueChanged(editorVisibilityChanged); + + Debug.Assert(skinEditor != null); + + LoadComponentAsync(skinEditor, AddInternal); + } + else + skinEditor.Show(); + } + private void editorVisibilityChanged(ValueChangedEvent visibility) { if (visibility.NewValue == Visibility.Visible) { - target.Masking = true; + updateMasking(); target.AllowScaling = false; target.RelativePositionAxes = Axes.Both; @@ -75,12 +101,20 @@ namespace osu.Game.Skinning.Editor { target.AllowScaling = true; - target.ScaleTo(1, SkinEditor.TRANSITION_DURATION, Easing.OutQuint).OnComplete(_ => target.Masking = false); + target.ScaleTo(1, SkinEditor.TRANSITION_DURATION, Easing.OutQuint).OnComplete(_ => updateMasking()); target.MoveToX(0f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); } } - public void OnReleased(GlobalAction action) + private void updateMasking() + { + if (skinEditor == null) + return; + + target.Masking = skinEditor.State.Value == Visibility.Visible; + } + + public void OnReleased(KeyBindingReleaseEvent e) { } diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index 17eb88226d..0790faad34 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -170,6 +170,8 @@ namespace osu.Game.Skinning.Editor SelectionBox.CanRotate = true; SelectionBox.CanScaleX = true; SelectionBox.CanScaleY = true; + SelectionBox.CanFlipX = true; + SelectionBox.CanFlipY = true; SelectionBox.CanReverse = false; } diff --git a/osu.Game/Skinning/ISkinSource.cs b/osu.Game/Skinning/ISkinSource.cs index ba3e2bf6ad..a5ed0fc990 100644 --- a/osu.Game/Skinning/ISkinSource.cs +++ b/osu.Game/Skinning/ISkinSource.cs @@ -12,6 +12,9 @@ namespace osu.Game.Skinning /// public interface ISkinSource : ISkin { + /// + /// Fired whenever a source change occurs, signalling that consumers should re-query as required. + /// event Action SourceChanged; /// diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index caf37e5bc9..2093182dcc 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -7,8 +7,11 @@ using osu.Framework.Graphics; using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Objects.Types; +using osuTK.Graphics; namespace osu.Game.Skinning { @@ -17,8 +20,8 @@ namespace osu.Game.Skinning protected override bool AllowManiaSkin => false; protected override bool UseCustomSampleBanks => true; - public LegacyBeatmapSkin(BeatmapInfo beatmap, IResourceStore storage, IStorageResourceProvider resources) - : base(createSkinInfo(beatmap), new LegacySkinResourceStore(beatmap.BeatmapSet, storage), resources, beatmap.Path) + public LegacyBeatmapSkin(BeatmapInfo beatmapInfo, IResourceStore storage, IStorageResourceProvider resources) + : base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path) { // Disallow default colours fallback on beatmap skins to allow using parent skin combo colours. (via SkinProvidingContainer) Configuration.AllowDefaultComboColoursFallback = false; @@ -59,6 +62,9 @@ namespace osu.Game.Skinning return base.GetConfig(lookup); } + protected override IBindable GetComboColour(IHasComboColours source, int comboIndex, IHasComboInformation combo) + => base.GetComboColour(source, combo.ComboIndexWithOffsets, combo); + public override ISample GetSample(ISampleInfo sampleInfo) { if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) @@ -70,7 +76,7 @@ namespace osu.Game.Skinning return base.GetSample(sampleInfo); } - private static SkinInfo createSkinInfo(BeatmapInfo beatmap) => - new SkinInfo { Name = beatmap.ToString(), Creator = beatmap.Metadata?.AuthorString }; + private static SkinInfo createSkinInfo(BeatmapInfo beatmapInfo) => + new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.AuthorString }; } } diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 67280e4acd..b1cd1f86c0 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Screens.Play.HUD; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -83,10 +84,10 @@ namespace osu.Game.Skinning private static Color4 getFillColour(double hp) { if (hp < 0.2) - return Interpolation.ValueAt(0.2 - hp, Color4.Black, Color4.Red, 0, 0.2); + return LegacyUtils.InterpolateNonLinear(0.2 - hp, Color4.Black, Color4.Red, 0, 0.2); if (hp < epic_cutoff) - return Interpolation.ValueAt(0.5 - hp, Color4.White, Color4.Black, 0, 0.5); + return LegacyUtils.InterpolateNonLinear(0.5 - hp, Color4.White, Color4.Black, 0, 0.5); return Color4.White; } 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 a12defe87e..0c9a82074f 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -16,7 +16,6 @@ namespace osu.Game.Skinning public bool UsesFixedAnchor { get; set; } public LegacyScoreCounter() - : base(6) { Anchor = Anchor.TopRight; Origin = Anchor.TopRight; diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index e255fbae81..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; @@ -129,6 +130,9 @@ 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())); @@ -286,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; diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index ec25268be4..fd1f905868 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -17,10 +18,12 @@ namespace osu.Game.Skinning { public static class LegacySkinExtensions { + [CanBeNull] public static Drawable GetAnimation(this ISkin source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", bool startAtCurrentTime = true, double? frameLength = null) => source.GetAnimation(componentName, default, default, animatable, looping, applyConfigFrameRate, animationSeparator, startAtCurrentTime, frameLength); + [CanBeNull] public static Drawable GetAnimation(this ISkin source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", bool startAtCurrentTime = true, double? frameLength = null) diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 7895fcccca..8fc6cbde7d 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -56,7 +56,7 @@ namespace osu.Game.Skinning if (texture == null) return null; - return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, null), texture, 1f / texture.ScaleAdjust); + return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); } private static string getLookupName(char character) diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs index 19efc66814..b884794739 100644 --- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -46,57 +48,48 @@ namespace osu.Game.Skinning }; } - private ISkinSource parentSource; - private ResourceStoreBackedSkin rulesetResourcesSkin; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - parentSource = parent.Get(); - parentSource.SourceChanged += OnSourceChanged; - if (Ruleset.CreateResourceStore() is IResourceStore resources) rulesetResourcesSkin = new ResourceStoreBackedSkin(resources, parent.Get(), parent.Get()); - // ensure sources are populated and ready for use before childrens' asynchronous load flow. - UpdateSkinSources(); - return base.CreateChildDependencies(parent); } - protected override void OnSourceChanged() + protected override void RefreshSources() { - UpdateSkinSources(); - base.OnSourceChanged(); - } + // Populate a local list first so we can adjust the returned order as we go. + var sources = new List(); - protected virtual void UpdateSkinSources() - { - SkinSources.Clear(); + Debug.Assert(ParentSource != null); - foreach (var skin in parentSource.AllSources) + foreach (var skin in ParentSource.AllSources) { switch (skin) { case LegacySkin legacySkin: - SkinSources.Add(GetLegacyRulesetTransformedSkin(legacySkin)); + sources.Add(GetLegacyRulesetTransformedSkin(legacySkin)); break; default: - SkinSources.Add(skin); + sources.Add(skin); break; } } - int lastDefaultSkinIndex = SkinSources.IndexOf(SkinSources.OfType().LastOrDefault()); + 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) - SkinSources.Insert(lastDefaultSkinIndex, rulesetResourcesSkin); + sources.Insert(lastDefaultSkinIndex, rulesetResourcesSkin); else - SkinSources.Add(rulesetResourcesSkin); + sources.Add(rulesetResourcesSkin); + + SetSources(sources); } protected ISkin GetLegacyRulesetTransformedSkin(ISkin legacySkin) @@ -115,9 +108,6 @@ namespace osu.Game.Skinning { base.Dispose(isDisposing); - if (parentSource != null) - parentSource.SourceChanged -= OnSourceChanged; - rulesetResourcesSkin?.Dispose(); } } diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index b6cb8fc7a4..92441f40da 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -11,6 +11,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; +using osu.Framework.Logging; using osu.Game.Audio; using osu.Game.IO; using osu.Game.Screens.Play.HUD; @@ -55,13 +56,20 @@ namespace osu.Game.Skinning if (bytes == null) continue; - string jsonContent = Encoding.UTF8.GetString(bytes); - var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + try + { + string jsonContent = Encoding.UTF8.GetString(bytes); + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); - if (deserializedContent == null) - continue; + if (deserializedContent == null) + continue; - DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to load skin configuration."); + } } } 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/SkinConfiguration.cs b/osu.Game/Skinning/SkinConfiguration.cs index 25a924c929..a18144246f 100644 --- a/osu.Game/Skinning/SkinConfiguration.cs +++ b/osu.Game/Skinning/SkinConfiguration.cs @@ -27,14 +27,14 @@ namespace osu.Game.Skinning new Color4(242, 24, 57, 255), }; - private readonly List comboColours = new List(); + public List CustomComboColours { get; set; } = new List(); public IReadOnlyList ComboColours { get { - if (comboColours.Count > 0) - return comboColours; + if (CustomComboColours.Count > 0) + return CustomComboColours; if (AllowDefaultComboColoursFallback) return DefaultComboColours; @@ -43,7 +43,7 @@ namespace osu.Game.Skinning } } - public void AddComboColours(params Color4[] colours) => comboColours.AddRange(colours); + void IHasComboColours.AddComboColours(params Color4[] colours) => CustomComboColours.AddRange(colours); public Dictionary CustomColours { get; } = new Dictionary(); diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 851d71f914..2bf8668ec6 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using osu.Framework.Extensions.ObjectExtensions; -using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; @@ -39,8 +38,6 @@ namespace osu.Game.Skinning public List Files { get; set; } = new List(); - public List Settings { get; set; } - public bool DeletePending { get; set; } public static SkinInfo Default { get; } = new SkinInfo diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index ea55fd28c2..3842acab74 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -105,12 +105,18 @@ namespace osu.Game.Skinning /// Returns a list of all usable s that have been loaded by the user. /// /// A newly allocated list of available . - public List GetAllUserSkins() => ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); + public List GetAllUserSkins(bool includeFiles = false) + { + if (includeFiles) + return ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); + + return ModelStore.Items.Where(s => !s.DeletePending).ToList(); + } public void SelectRandomSkin() { // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = GetAllUsableSkins().Where(s => s.ID != CurrentSkinInfo.Value.ID).ToArray(); + var randomChoices = ModelStore.Items.Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); if (randomChoices.Length == 0) { @@ -118,7 +124,8 @@ namespace osu.Game.Skinning return; } - CurrentSkinInfo.Value = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); + var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); + CurrentSkinInfo.Value = ModelStore.ConsumableItems.Single(i => i.ID == chosen.ID); } protected override SkinInfo CreateModel(ArchiveReader archive) => new SkinInfo { Name = archive.Name }; @@ -129,18 +136,19 @@ namespace osu.Game.Skinning protected override string ComputeHash(SkinInfo item, ArchiveReader reader = null) { - // we need to populate early to create a hash based off skin.ini contents - if (item.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) - populateMetadata(item, GetSkin(item)); + var instance = GetSkin(item); - if (item.Creator != null && item.Creator != unknown_creator_string) + // in the case the skin has a skin.ini file, we are going to create a hash based on that. + // we don't want to do this in the case we don't have a skin.ini, as it would match only on the filename portion, + // causing potentially unique skin imports to be considered as a duplicate. + if (!string.IsNullOrEmpty(instance.Configuration.SkinInfo.Name)) { - // this is the optimal way to hash legacy skins, but will need to be reconsidered when we move forward with skin implementation. - // likely, the skin should expose a real version (ie. the version of the skin, not the skin.ini version it's targeting). + // we need to populate early to create a hash based off skin.ini contents + populateMetadata(item, instance, reader?.Name); + return item.ToString().ComputeSHA2Hash(); } - // if there was no creator, the ToString above would give the filename, which alone isn't really enough to base any decisions on. return base.ComputeHash(item, reader); } @@ -150,13 +158,12 @@ namespace osu.Game.Skinning model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); - if (model.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) - populateMetadata(model, instance); + populateMetadata(model, instance, archive?.Name); return Task.CompletedTask; } - private void populateMetadata(SkinInfo item, Skin instance) + private void populateMetadata(SkinInfo item, Skin instance, string archiveName) { if (!string.IsNullOrEmpty(instance.Configuration.SkinInfo.Name)) { @@ -168,6 +175,13 @@ namespace osu.Game.Skinning item.Name = item.Name.Replace(".osk", "", StringComparison.OrdinalIgnoreCase); item.Creator ??= unknown_creator_string; } + + // generally when importing from a folder, the ".osk" extension will not be present. + // if we ever need a more reliable method of determining this, the type of `ArchiveReader` can be checked. + bool isArchiveImport = archiveName?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true; + + if (archiveName != null && !isArchiveImport && archiveName != item.Name) + item.Name = $"{item.Name} [{archiveName}]"; } /// @@ -193,7 +207,7 @@ namespace osu.Game.Skinning Name = skin.SkinInfo.Name + " (modified)", Creator = skin.SkinInfo.Creator, InstantiationInfo = skin.SkinInfo.InstantiationInfo, - }).Result; + }).Result.Value; } public void Save(Skin skin) diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index c83c299723..c8e4c2c7b6 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Collections.Specialized; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -24,19 +23,8 @@ namespace osu.Game.Skinning { public event Action SourceChanged; - /// - /// Skins which should be exposed by this container, in order of lookup precedence. - /// - protected readonly BindableList SkinSources = new BindableList(); - - /// - /// A dictionary mapping each from the - /// to one that performs the "allow lookup" checks before proceeding with a lookup. - /// - private readonly Dictionary disableableSkinSources = new Dictionary(); - [CanBeNull] - private ISkinSource fallbackSource; + protected ISkinSource ParentSource { get; private set; } /// /// Whether falling back to parent s is allowed in this container. @@ -53,6 +41,13 @@ namespace osu.Game.Skinning protected virtual bool AllowColourLookup => true; + private readonly object sourceSetLock = new object(); + + /// + /// A dictionary mapping each source to a wrapper which handles lookup allowances. + /// + private (ISkin skin, DisableableSkinSource wrapped)[] skinSources = Array.Empty<(ISkin skin, DisableableSkinSource wrapped)>(); + /// /// Constructs a new initialised with a single skin source. /// @@ -60,87 +55,56 @@ namespace osu.Game.Skinning : this() { if (skin != null) - SkinSources.Add(skin); + SetSources(new[] { skin }); } /// /// Constructs a new with no sources. - /// Implementations can add or change sources through the list. /// protected SkinProvidingContainer() { RelativeSizeAxes = Axes.Both; + } - SkinSources.BindCollectionChanged(((_, args) => - { - switch (args.Action) - { - case NotifyCollectionChangedAction.Add: - foreach (var skin in args.NewItems.Cast()) - { - disableableSkinSources.Add(skin, new DisableableSkinSource(skin, this)); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - if (skin is ISkinSource source) - source.SourceChanged += OnSourceChanged; - } + ParentSource = dependencies.Get(); + if (ParentSource != null) + ParentSource.SourceChanged += TriggerSourceChanged; - break; + dependencies.CacheAs(this); - case NotifyCollectionChangedAction.Reset: - case NotifyCollectionChangedAction.Remove: - foreach (var skin in args.OldItems.Cast()) - { - disableableSkinSources.Remove(skin); + TriggerSourceChanged(); - if (skin is ISkinSource source) - source.SourceChanged -= OnSourceChanged; - } - - break; - - case NotifyCollectionChangedAction.Replace: - foreach (var skin in args.OldItems.Cast()) - { - disableableSkinSources.Remove(skin); - - if (skin is ISkinSource source) - source.SourceChanged -= OnSourceChanged; - } - - foreach (var skin in args.NewItems.Cast()) - { - disableableSkinSources.Add(skin, new DisableableSkinSource(skin, this)); - - if (skin is ISkinSource source) - source.SourceChanged += OnSourceChanged; - } - - break; - } - }), true); + return dependencies; } public ISkin FindProvider(Func lookupFunction) { - foreach (var skin in SkinSources) + foreach (var (skin, lookupWrapper) in skinSources) { - if (lookupFunction(disableableSkinSources[skin])) + if (lookupFunction(lookupWrapper)) return skin; } - return fallbackSource?.FindProvider(lookupFunction); + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.FindProvider(lookupFunction); } public IEnumerable AllSources { get { - foreach (var skin in SkinSources) - yield return skin; + foreach (var i in skinSources) + yield return i.skin; - if (fallbackSource != null) + if (AllowFallingBackToParent && ParentSource != null) { - foreach (var skin in fallbackSource.AllSources) + foreach (var skin in ParentSource.AllSources) yield return skin; } } @@ -148,68 +112,103 @@ namespace osu.Game.Skinning public Drawable GetDrawableComponent(ISkinComponent component) { - foreach (var skin in SkinSources) + foreach (var (_, lookupWrapper) in skinSources) { Drawable sourceDrawable; - if ((sourceDrawable = disableableSkinSources[skin]?.GetDrawableComponent(component)) != null) + if ((sourceDrawable = lookupWrapper.GetDrawableComponent(component)) != null) return sourceDrawable; } - return fallbackSource?.GetDrawableComponent(component); + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.GetDrawableComponent(component); } public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { - foreach (var skin in SkinSources) + foreach (var (_, lookupWrapper) in skinSources) { Texture sourceTexture; - if ((sourceTexture = disableableSkinSources[skin]?.GetTexture(componentName, wrapModeS, wrapModeT)) != null) + if ((sourceTexture = lookupWrapper.GetTexture(componentName, wrapModeS, wrapModeT)) != null) return sourceTexture; } - return fallbackSource?.GetTexture(componentName, wrapModeS, wrapModeT); + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.GetTexture(componentName, wrapModeS, wrapModeT); } public ISample GetSample(ISampleInfo sampleInfo) { - foreach (var skin in SkinSources) + foreach (var (_, lookupWrapper) in skinSources) { ISample sourceSample; - if ((sourceSample = disableableSkinSources[skin]?.GetSample(sampleInfo)) != null) + if ((sourceSample = lookupWrapper.GetSample(sampleInfo)) != null) return sourceSample; } - return fallbackSource?.GetSample(sampleInfo); + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.GetSample(sampleInfo); } public IBindable GetConfig(TLookup lookup) { - foreach (var skin in SkinSources) + foreach (var (_, lookupWrapper) in skinSources) { IBindable bindable; - if ((bindable = disableableSkinSources[skin]?.GetConfig(lookup)) != null) + if ((bindable = lookupWrapper.GetConfig(lookup)) != null) return bindable; } - return fallbackSource?.GetConfig(lookup); + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.GetConfig(lookup); } - protected virtual void OnSourceChanged() => SourceChanged?.Invoke(); - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + /// + /// Replace the sources used for lookups in this container. + /// + /// + /// This does not implicitly fire a event. Consider calling if required. + /// + /// The new sources. + protected void SetSources(IEnumerable sources) { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - if (AllowFallingBackToParent) + lock (sourceSetLock) { - fallbackSource = dependencies.Get(); - if (fallbackSource != null) - fallbackSource.SourceChanged += OnSourceChanged; + foreach (var skin in skinSources) + { + if (skin.skin is ISkinSource source) + source.SourceChanged -= TriggerSourceChanged; + } + + skinSources = sources.Select(skin => (skin, new DisableableSkinSource(skin, this))).ToArray(); + + foreach (var skin in skinSources) + { + if (skin.skin is ISkinSource source) + source.SourceChanged += TriggerSourceChanged; + } } + } - dependencies.CacheAs(this); + /// + /// Invoked after any consumed source change, before the external event is fired. + /// This is also invoked once initially during to ensure sources are ready for children consumption. + /// + protected virtual void RefreshSources() { } - return dependencies; + protected void TriggerSourceChanged() + { + // Expose to implementations, giving them a chance to react before notifying external consumers. + RefreshSources(); + + SourceChanged?.Invoke(); } protected override void Dispose(bool isDisposing) @@ -219,11 +218,14 @@ namespace osu.Game.Skinning base.Dispose(isDisposing); - if (fallbackSource != null) - fallbackSource.SourceChanged -= OnSourceChanged; + if (ParentSource != null) + ParentSource.SourceChanged -= TriggerSourceChanged; - foreach (var source in SkinSources.OfType()) - source.SourceChanged -= OnSourceChanged; + foreach (var i in skinSources) + { + if (i.skin is ISkinSource source) + source.SourceChanged -= TriggerSourceChanged; + } } private class DisableableSkinSource : ISkin @@ -266,6 +268,7 @@ namespace osu.Game.Skinning switch (lookup) { case GlobalSkinColours _: + case SkinComboColourLookup _: case SkinCustomColourLookup _: if (provider.AllowColourLookup) return skin.GetConfig(lookup); diff --git a/osu.Game/Skinning/SkinStore.cs b/osu.Game/Skinning/SkinStore.cs index 153eeda130..31cadb0a24 100644 --- a/osu.Game/Skinning/SkinStore.cs +++ b/osu.Game/Skinning/SkinStore.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; -using Microsoft.EntityFrameworkCore; using osu.Framework.Platform; using osu.Game.Database; @@ -14,9 +12,5 @@ namespace osu.Game.Skinning : base(contextFactory, storage) { } - - protected override IQueryable AddIncludesForDeletion(IQueryable query) => - base.AddIncludesForDeletion(query) - .Include(s => s.Settings); // don't include FileInfo. these are handled by the FileStore itself. } } diff --git a/osu.Game/Skinning/SkinnableTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs index 53b142f09a..20c2fcc075 100644 --- a/osu.Game/Skinning/SkinnableTargetContainer.cs +++ b/osu.Game/Skinning/SkinnableTargetContainer.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -18,8 +19,12 @@ namespace osu.Game.Skinning private readonly BindableList components = new BindableList(); + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; // ensure that components are loaded even if the target container is hidden (ie. due to user toggle). + public bool ComponentsLoaded { get; private set; } + private CancellationTokenSource cancellationSource; + public SkinnableTargetContainer(SkinnableTarget target) { Target = target; @@ -36,6 +41,9 @@ namespace osu.Game.Skinning content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer; + cancellationSource?.Cancel(); + cancellationSource = null; + if (content != null) { LoadComponentAsync(content, wrapper => @@ -43,7 +51,7 @@ namespace osu.Game.Skinning AddInternal(wrapper); components.AddRange(wrapper.Children.OfType()); ComponentsLoaded = true; - }); + }, (cancellationSource = new CancellationTokenSource()).Token); } else ComponentsLoaded = true; diff --git a/osu.Game/Stores/RealmFileStore.cs b/osu.Game/Stores/RealmFileStore.cs new file mode 100644 index 0000000000..f7b7471634 --- /dev/null +++ b/osu.Game/Stores/RealmFileStore.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.IO; +using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.IO.Stores; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Models; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Handles the storing of files to the file system (and database) backing. + /// + [ExcludeFromDynamicCompile] + public class RealmFileStore + { + private readonly RealmContextFactory realmFactory; + + public readonly IResourceStore Store; + + public readonly Storage Storage; + + public RealmFileStore(RealmContextFactory realmFactory, Storage storage) + { + this.realmFactory = realmFactory; + + Storage = storage.GetStorageForDirectory(@"files"); + Store = new StorageBackedResourceStore(Storage); + } + + /// + /// Add a new file to the game-wide database, copying it to permanent storage if not already present. + /// + /// The file data stream. + /// The realm instance to add to. Should already be in a transaction. + /// + public RealmFile Add(Stream data, Realm realm) + { + string hash = data.ComputeSHA2Hash(); + + var existing = realm.Find(hash); + + var file = existing ?? new RealmFile { Hash = hash }; + + if (!checkFileExistsAndMatchesHash(file)) + copyToStore(file, data); + + if (!file.IsManaged) + realm.Add(file); + + return file; + } + + private void copyToStore(RealmFile file, Stream data) + { + data.Seek(0, SeekOrigin.Begin); + + using (var output = Storage.GetStream(file.StoragePath, FileAccess.Write)) + data.CopyTo(output); + + data.Seek(0, SeekOrigin.Begin); + } + + private bool checkFileExistsAndMatchesHash(RealmFile file) + { + string path = file.StoragePath; + + // we may be re-adding a file to fix missing store entries. + if (!Storage.Exists(path)) + return false; + + // even if the file already exists, check the existing checksum for safety. + using (var stream = Storage.GetStream(path)) + return stream.ComputeSHA2Hash() == file.Hash; + } + + public void Cleanup() + { + var realm = realmFactory.Context; + + // can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal. + using (var transaction = realm.BeginWrite()) + { + // TODO: consider using a realm native query to avoid iterating all files (https://github.com/realm/realm-dotnet/issues/2659#issuecomment-927823707) + var files = realm.All().ToList(); + + foreach (var file in files) + { + if (file.BacklinksCount > 0) + continue; + + try + { + Storage.Delete(file.StoragePath); + realm.Remove(file); + } + catch (Exception e) + { + Logger.Error(e, $@"Could not delete databased file {file.Hash}"); + } + } + + transaction.Commit(); + } + } + } +} diff --git a/osu.Game/Storyboards/CommandLoop.cs b/osu.Game/Storyboards/CommandLoop.cs index c22ca0d8c0..66db965803 100644 --- a/osu.Game/Storyboards/CommandLoop.cs +++ b/osu.Game/Storyboards/CommandLoop.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; namespace osu.Game.Storyboards @@ -8,20 +9,31 @@ namespace osu.Game.Storyboards public class CommandLoop : CommandTimelineGroup { public double LoopStartTime; - public int LoopCount; + + /// + /// The total number of times this loop is played back. Always greater than zero. + /// + public readonly int TotalIterations; public override double StartTime => LoopStartTime + CommandsStartTime; - public override double EndTime => StartTime + CommandsDuration * LoopCount; + public override double EndTime => StartTime + CommandsDuration * TotalIterations; - public CommandLoop(double startTime, int loopCount) + /// + /// Construct a new command loop. + /// + /// The start time of the loop. + /// The number of times the loop should repeat. Should be greater than zero. Zero means a single playback. + public CommandLoop(double startTime, int repeatCount) { + if (repeatCount < 0) throw new ArgumentException("Repeat count must be zero or above.", nameof(repeatCount)); + LoopStartTime = startTime; - LoopCount = loopCount; + TotalIterations = repeatCount + 1; } public override IEnumerable.TypedCommand> GetCommands(CommandTimelineSelector timelineSelector, double offset = 0) { - for (var loop = 0; loop < LoopCount; loop++) + for (var loop = 0; loop < TotalIterations; loop++) { var loopOffset = LoopStartTime + loop * CommandsDuration; foreach (var command in base.GetCommands(timelineSelector, offset + loopOffset)) @@ -30,6 +42,6 @@ namespace osu.Game.Storyboards } public override string ToString() - => $"{LoopStartTime} x{LoopCount}"; + => $"{LoopStartTime} x{TotalIterations}"; } } diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index bf87e7d10e..6fb2f5994b 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -1,13 +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 osuTK; -using osu.Framework.Graphics; -using osu.Game.Storyboards.Drawables; using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; +using osu.Framework.Graphics; +using osu.Game.Storyboards.Drawables; +using osuTK; namespace osu.Game.Storyboards { @@ -78,9 +78,9 @@ namespace osu.Game.Storyboards InitialPosition = initialPosition; } - public CommandLoop AddLoop(double startTime, int loopCount) + public CommandLoop AddLoop(double startTime, int repeatCount) { - var loop = new CommandLoop(startTime, loopCount); + var loop = new CommandLoop(startTime, repeatCount); loops.Add(loop); return loop; } diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index 4935f7fc13..6d63525011 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.IO; +using osu.Game.IO.Serialization; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -109,6 +110,8 @@ namespace osu.Game.Tests.Beatmaps { var beatmap = GetBeatmap(name); + string beforeConversion = beatmap.Serialize(); + var converterResult = new Dictionary>(); var working = new ConversionWorkingBeatmap(beatmap) @@ -122,6 +125,10 @@ namespace osu.Game.Tests.Beatmaps working.GetPlayableBeatmap(CreateRuleset().RulesetInfo, mods); + string afterConversion = beatmap.Serialize(); + + Assert.AreEqual(beforeConversion, afterConversion, "Conversion altered original beatmap"); + return new ConvertResult { Mappings = converterResult.Select(r => @@ -217,7 +224,7 @@ namespace osu.Game.Tests.Beatmaps protected override Track GetBeatmapTrack() => throw new NotImplementedException(); - protected override ISkin GetSkin() => throw new NotImplementedException(); + protected internal override ISkin GetSkin() => throw new NotImplementedException(); public override Stream GetStream(string storagePath) => throw new NotImplementedException(); diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs index 76f229a799..fdb3e1d465 100644 --- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs +++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs @@ -23,7 +23,10 @@ namespace osu.Game.Tests.Beatmaps protected abstract string ResourceAssembly { get; } protected void Test(double expected, string name, params Mod[] mods) - => Assert.AreEqual(expected, CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods).StarRating); + { + // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. + Assert.That(CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods).StarRating, Is.EqualTo(expected).Within(0.00001)); + } private WorkingBeatmap getBeatmap(string name) { diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index 7ee6c519b7..bb5dd09e16 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -211,7 +211,7 @@ namespace osu.Game.Tests.Beatmaps this.resources = resources; } - protected override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, resourceStore, resources); + protected internal override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, resourceStore, resources); } } } diff --git a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs index e0c2965fa0..5c522058d9 100644 --- a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Screens.Play; using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK.Graphics; @@ -60,16 +59,12 @@ namespace osu.Game.Tests.Beatmaps protected virtual ExposedPlayer CreateTestPlayer(bool userHasCustomColours) => new ExposedPlayer(userHasCustomColours); - protected class ExposedPlayer : Player + protected class ExposedPlayer : TestPlayer { protected readonly bool UserHasCustomColours; public ExposedPlayer(bool userHasCustomColours) - : base(new PlayerConfiguration - { - AllowPause = false, - ShowResults = false, - }) + : base(false, false) { UserHasCustomColours = userHasCustomColours; } @@ -97,7 +92,7 @@ namespace osu.Game.Tests.Beatmaps HasColours = hasColours; } - protected override ISkin GetSkin() => new TestBeatmapSkin(BeatmapInfo, HasColours); + protected internal override ISkin GetSkin() => new TestBeatmapSkin(BeatmapInfo, HasColours); } protected class TestBeatmapSkin : LegacyBeatmapSkin @@ -106,6 +101,8 @@ namespace osu.Game.Tests.Beatmaps { new Color4(50, 100, 150, 255), new Color4(40, 80, 120, 255), + new Color4(25, 50, 75, 255), + new Color4(10, 20, 30, 255), }; public static readonly Color4 HYPER_DASH_COLOUR = Color4.DarkBlue; @@ -114,12 +111,12 @@ namespace osu.Game.Tests.Beatmaps public static readonly Color4 HYPER_DASH_FRUIT_COLOUR = Color4.DarkGoldenrod; - public TestBeatmapSkin(BeatmapInfo beatmap, bool hasColours) - : base(beatmap, new ResourceStore(), null) + public TestBeatmapSkin(BeatmapInfo beatmapInfo, bool hasColours) + : base(beatmapInfo, new ResourceStore(), null) { if (hasColours) { - Configuration.AddComboColours(Colours); + Configuration.CustomComboColours = Colours.ToList(); Configuration.CustomColours.Add("HyperDash", HYPER_DASH_COLOUR); Configuration.CustomColours.Add("HyperDashAfterImage", HYPER_DASH_AFTER_IMAGE_COLOUR); Configuration.CustomColours.Add("HyperDashFruit", HYPER_DASH_FRUIT_COLOUR); @@ -133,6 +130,8 @@ namespace osu.Game.Tests.Beatmaps { new Color4(150, 100, 50, 255), new Color4(20, 20, 20, 255), + new Color4(75, 50, 25, 255), + new Color4(80, 80, 80, 255), }; public static readonly Color4 HYPER_DASH_COLOUR = Color4.LightBlue; @@ -146,7 +145,7 @@ namespace osu.Game.Tests.Beatmaps { if (hasCustomColours) { - Configuration.AddComboColours(Colours); + Configuration.CustomComboColours = Colours.ToList(); Configuration.CustomColours.Add("HyperDash", HYPER_DASH_COLOUR); Configuration.CustomColours.Add("HyperDashAfterImage", HYPER_DASH_AFTER_IMAGE_COLOUR); Configuration.CustomColours.Add("HyperDashFruit", HYPER_DASH_FRUIT_COLOUR); diff --git a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs index 54a83f4305..b7803f3420 100644 --- a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Beatmaps protected void TestToLegacy(LegacyMods expectedLegacyMods, Type[] providedModTypes) { var ruleset = CreateRuleset(); - var modInstances = ruleset.GetAllMods() + var modInstances = ruleset.CreateAllMods() .Where(mod => providedModTypes.Contains(mod.GetType())) .ToArray(); var actualLegacyMods = ruleset.ConvertToLegacyMods(modInstances); diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs index bfce59c7de..19974701db 100644 --- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Beatmaps protected override Storyboard GetStoryboard() => storyboard ?? base.GetStoryboard(); - protected override ISkin GetSkin() => null; + protected internal override ISkin GetSkin() => null; public override Stream GetStream(string storagePath) => null; diff --git a/osu.Game/Tests/CleanRunHeadlessGameHost.cs b/osu.Game/Tests/CleanRunHeadlessGameHost.cs index baa7b27d28..d7ab769ac4 100644 --- a/osu.Game/Tests/CleanRunHeadlessGameHost.cs +++ b/osu.Game/Tests/CleanRunHeadlessGameHost.cs @@ -2,14 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System.Runtime.CompilerServices; -using osu.Framework.Platform; +using osu.Framework.Testing; namespace osu.Game.Tests { /// /// A headless host which cleans up before running (removing any remnants from a previous execution). /// - public class CleanRunHeadlessGameHost : HeadlessGameHost + public class CleanRunHeadlessGameHost : TestRunHeadlessGameHost { /// /// Create a new instance. @@ -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/TestScoreInfo.cs b/osu.Game/Tests/TestScoreInfo.cs index 9090a12d3f..719d31b092 100644 --- a/osu.Game/Tests/TestScoreInfo.cs +++ b/osu.Game/Tests/TestScoreInfo.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -13,7 +14,7 @@ namespace osu.Game.Tests { public class TestScoreInfo : ScoreInfo { - public TestScoreInfo(RulesetInfo ruleset) + public TestScoreInfo(RulesetInfo ruleset, bool excessMods = false) { User = new User { @@ -22,10 +23,13 @@ namespace osu.Game.Tests CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }; - Beatmap = new TestBeatmap(ruleset).BeatmapInfo; + BeatmapInfo = new TestBeatmap(ruleset).BeatmapInfo; Ruleset = ruleset; RulesetID = ruleset.ID ?? 0; - Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }; + + Mods = excessMods + ? ruleset.CreateInstance().CreateAllMods().ToArray() + : new Mod[] { new TestModHardRock(), new TestModDoubleTime() }; TotalScore = 2845370; Accuracy = 0.95; 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..798b0d01ee 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -16,26 +16,38 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Screens.Menu; using osu.Game.Skinning; namespace osu.Game.Tests.Visual { public abstract class EditorTestScene : ScreenTestScene { - protected EditorBeatmap EditorBeatmap; + private TestEditorLoader editorLoader; - protected TestEditor Editor { get; private set; } + protected TestEditor Editor => editorLoader.Editor; - protected EditorClock EditorClock { get; private set; } + protected EditorBeatmap EditorBeatmap => Editor.ChildrenOfType().Single(); + protected EditorClock EditorClock => Editor.ChildrenOfType().Single(); /// /// Whether any saves performed by the editor should be isolate (and not persist) to the underlying . /// protected virtual bool IsolateSavingFromDatabase => true; + // required for screen transitions to work properly + // (see comment in EditorLoader.LogoArriving). + [Cached] + private OsuLogo logo = new OsuLogo + { + Alpha = 0 + }; + [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio, RulesetStore rulesets) { + Add(logo); + var working = CreateWorkingBeatmap(Ruleset.Value); Beatmap.Value = working; @@ -53,13 +65,11 @@ namespace osu.Game.Tests.Visual AddStep("load editor", LoadEditor); AddUntilStep("wait for editor to load", () => EditorComponentsReady); - AddStep("get beatmap", () => EditorBeatmap = Editor.ChildrenOfType().Single()); - AddStep("get clock", () => EditorClock = Editor.ChildrenOfType().Single()); } protected virtual void LoadEditor() { - LoadScreen(Editor = CreateEditor()); + LoadScreen(editorLoader = new TestEditorLoader()); } /// @@ -70,7 +80,14 @@ namespace osu.Game.Tests.Visual protected sealed override Ruleset CreateRuleset() => CreateEditorRuleset(); - protected virtual TestEditor CreateEditor() => new TestEditor(); + protected class TestEditorLoader : EditorLoader + { + public TestEditor Editor { get; private set; } + + protected sealed override Editor CreateEditor() => Editor = CreateTestEditor(this); + + protected virtual TestEditor CreateTestEditor(EditorLoader loader) => new TestEditor(loader); + } protected class TestEditor : Editor { @@ -86,7 +103,14 @@ namespace osu.Game.Tests.Visual public new void Paste() => base.Paste(); + public new void SwitchToDifficulty(BeatmapInfo beatmapInfo) => base.SwitchToDifficulty(beatmapInfo); + public new bool HasUnsavedChanges => base.HasUnsavedChanges; + + public TestEditor(EditorLoader loader = null) + : base(loader) + { + } } private class TestBeatmapManager : BeatmapManager @@ -94,16 +118,45 @@ 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; } - protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null) - => string.Empty; + protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host) + { + return new TestBeatmapModelManager(storage, contextFactory, rulesets, api, host); + } - public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo) - => testBeatmap; + protected override WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap defaultBeatmap, GameHost host) + { + return new TestWorkingBeatmapCache(this, audioManager, resources, storage, defaultBeatmap, host); + } + + private class TestWorkingBeatmapCache : WorkingBeatmapCache + { + private readonly TestBeatmapManager testBeatmapManager; + + public TestWorkingBeatmapCache(TestBeatmapManager testBeatmapManager, AudioManager audioManager, IResourceStore resourceStore, IResourceStore storage, WorkingBeatmap defaultBeatmap, GameHost gameHost) + : base(audioManager, resourceStore, storage, defaultBeatmap, gameHost) + { + this.testBeatmapManager = testBeatmapManager; + } + + public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo) + => testBeatmapManager.testBeatmap; + } + + internal class TestBeatmapModelManager : BeatmapModelManager + { + public TestBeatmapModelManager(Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost) + : base(storage, databaseContextFactory, rulesetStore, gameHost) + { + } + + protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null) + => string.Empty; + } public override void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) { diff --git a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs index 204c189591..3362ebbbd6 100644 --- a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// The cached . /// - new TestMultiplayerRoomManager RoomManager { get; } + new TestRequestHandlingMultiplayerRoomManager RoomManager { get; } /// /// The cached . diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index b7d3793ab1..f259784170 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -18,7 +18,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public const int PLAYER_2_ID = 56; public TestMultiplayerClient Client => OnlinePlayDependencies.Client; - public new TestMultiplayerRoomManager RoomManager => OnlinePlayDependencies.RoomManager; + public new TestRequestHandlingMultiplayerRoomManager RoomManager => OnlinePlayDependencies.RoomManager; public TestUserLookupCache LookupCache => OnlinePlayDependencies?.LookupCache; public TestSpectatorClient SpectatorClient => OnlinePlayDependencies?.SpectatorClient; @@ -36,24 +36,29 @@ namespace osu.Game.Tests.Visual.Multiplayer { if (joinRoom) { - var room = new Room - { - Name = { Value = "test name" }, - Playlist = - { - new PlaylistItem - { - Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, - Ruleset = { Value = Ruleset.Value } - } - } - }; + var room = CreateRoom(); RoomManager.CreateRoom(room); SelectedRoom.Value = room; } }); + protected virtual Room CreateRoom() + { + return new Room + { + Name = { Value = "test name" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, + Ruleset = { Value = Ruleset.Value } + } + } + }; + } + public override void SetUpSteps() { base.SetUpSteps(); diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs index a2b0b066a7..2e13fb6a56 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs @@ -18,7 +18,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public TestMultiplayerClient Client { get; } public TestUserLookupCache LookupCache { get; } public TestSpectatorClient SpectatorClient { get; } - public new TestMultiplayerRoomManager RoomManager => (TestMultiplayerRoomManager)base.RoomManager; + public new TestRequestHandlingMultiplayerRoomManager RoomManager => (TestRequestHandlingMultiplayerRoomManager)base.RoomManager; public MultiplayerTestSceneDependencies() { @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Visual.Multiplayer CacheAs(SpectatorClient); } - protected override IRoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); + protected override IRoomManager CreateRoomManager() => new TestRequestHandlingMultiplayerRoomManager(); 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 b0c8d6d19b..2c0ca0b872 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -14,6 +14,7 @@ using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; using osu.Game.Users; @@ -28,7 +29,7 @@ namespace osu.Game.Tests.Visual.Multiplayer 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; @@ -38,9 +39,9 @@ namespace osu.Game.Tests.Visual.Multiplayer [Resolved] private BeatmapManager beatmaps { get; set; } = null!; - private readonly TestMultiplayerRoomManager roomManager; + private readonly TestRequestHandlingMultiplayerRoomManager roomManager; - public TestMultiplayerClient(TestMultiplayerRoomManager roomManager) + public TestMultiplayerClient(TestRequestHandlingMultiplayerRoomManager roomManager) { this.roomManager = roomManager; } @@ -49,9 +50,18 @@ namespace osu.Game.Tests.Visual.Multiplayer public void Disconnect() => isConnected.Value = false; - public void AddUser(User user) => ((IMultiplayerClient)this).UserJoined(new MultiplayerRoomUser(user.Id) { User = user }); + public MultiplayerRoomUser AddUser(User user, bool markAsPlaying = false) + { + var roomUser = new MultiplayerRoomUser(user.Id) { User = user }; + ((IMultiplayerClient)this).UserJoined(roomUser); - public void AddNullUser(int userId) => ((IMultiplayerClient)this).UserJoined(new MultiplayerRoomUser(userId)); + if (markAsPlaying) + PlayingUserIds.Add(user.Id); + + return roomUser; + } + + public void AddNullUser() => ((IMultiplayerClient)this).UserJoined(new MultiplayerRoomUser(TestUserLookupCache.NULL_USER_ID)); public void RemoveUser(User user) { @@ -115,9 +125,12 @@ 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); + var apiRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId); + + if (password != apiRoom.Password.Value) + throw new InvalidOperationException("Invalid password."); var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) { @@ -129,12 +142,15 @@ namespace osu.Game.Tests.Visual.Multiplayer Settings = { Name = apiRoom.Name.Value, + MatchType = apiRoom.Type.Value, BeatmapID = apiRoom.Playlist.Last().BeatmapID, RulesetID = apiRoom.Playlist.Last().RulesetID, BeatmapChecksum = apiRoom.Playlist.Last().Beatmap.Value.MD5Hash, RequiredMods = apiRoom.Playlist.Last().RequiredMods.Select(m => new APIMod(m)).ToArray(), AllowedMods = apiRoom.Playlist.Last().AllowedMods.Select(m => new APIMod(m)).ToArray(), - PlaylistItemId = apiRoom.Playlist.Last().ID + 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 @@ -143,19 +159,28 @@ namespace osu.Game.Tests.Visual.Multiplayer RoomSetupAction?.Invoke(room); RoomSetupAction = null; - APIRoom = apiRoom; - return Task.FromResult(room); } - protected override Task LeaveRoomInternal() + protected override void OnRoomJoined() { - APIRoom = null; - return Task.CompletedTask; + Debug.Assert(Room != null); + + // emulate the server sending this after the join room. scheduler required to make sure the join room event is fired first (in Join). + changeMatchType(Room.Settings.MatchType).Wait(); } + protected override Task LeaveRoomInternal() => Task.CompletedTask; + public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); + public override Task KickUser(int userId) + { + Debug.Assert(Room != null); + + return ((IMultiplayerClient)this).UserKicked(Room.Users.Single(u => u.UserID == userId)); + } + public override async Task ChangeSettings(MultiplayerRoomSettings settings) { Debug.Assert(Room != null); @@ -164,6 +189,8 @@ namespace osu.Game.Tests.Visual.Multiplayer foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) ChangeUserState(user.UserID, MultiplayerUserState.Idle); + + await changeMatchType(settings.MatchType).ConfigureAwait(false); } public override Task ChangeState(MultiplayerUserState newState) @@ -193,6 +220,31 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + public override async Task SendMatchRequest(MatchUserRequest request) + { + Debug.Assert(Room != null); + Debug.Assert(LocalUser != null); + + switch (request) + { + case ChangeTeamRequest changeTeam: + + TeamVersusRoomState roomState = (TeamVersusRoomState)Room.MatchState!; + TeamVersusUserState userState = (TeamVersusUserState)LocalUser.MatchState!; + + var targetTeam = roomState.Teams.FirstOrDefault(t => t.ID == changeTeam.TeamID); + + if (targetTeam != null) + { + userState.TeamID = targetTeam.ID; + + await ((IMultiplayerClient)this).MatchUserStateChanged(LocalUser.UserID, userState).ConfigureAwait(false); + } + + break; + } + } + public override Task StartMatch() { Debug.Assert(Room != null); @@ -208,7 +260,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Debug.Assert(Room != null); - var apiRoom = roomManager.Rooms.Single(r => r.RoomID.Value == Room.RoomID); + var apiRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == Room.RoomID); var set = apiRoom.Playlist.FirstOrDefault(p => p.BeatmapID == beatmapId)?.Beatmap.Value.BeatmapSet ?? beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId)?.BeatmapSet; @@ -217,5 +269,27 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.FromResult(set); } + + private async Task changeMatchType(MatchType type) + { + Debug.Assert(Room != null); + + switch (type) + { + case MatchType.HeadToHead: + await ((IMultiplayerClient)this).MatchRoomStateChanged(null).ConfigureAwait(false); + + foreach (var user in Room.Users) + await ((IMultiplayerClient)this).MatchUserStateChanged(user.UserID, null).ConfigureAwait(false); + break; + + case MatchType.TeamVersus: + await ((IMultiplayerClient)this).MatchRoomStateChanged(TeamVersusRoomState.CreateDefault()).ConfigureAwait(false); + + foreach (var user in Room.Users) + await ((IMultiplayerClient)this).MatchUserStateChanged(user.UserID, new TeamVersusUserState()).ConfigureAwait(false); + break; + } + } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs deleted file mode 100644 index 5d66cdba02..0000000000 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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.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] - private IAPIProvider api { get; set; } - - [Resolved] - private OsuGameBase game { get; set; } - - [Cached] - public readonly Bindable Filter = new Bindable(new FilterCriteria()); - - public new readonly List Rooms = new List(); - - [BackgroundDependencyLoader] - private void load() - { - int currentScoreId = 0; - int currentRoomId = 0; - int currentPlaylistItemId = 0; - - ((DummyAPIAccess)api).HandleRequest = req => - { - switch (req) - { - case CreateRoomRequest createRoomRequest: - var createdRoom = new APICreatedRoom(); - - createdRoom.CopyFrom(createRoomRequest.Room); - createdRoom.RoomID.Value ??= currentRoomId++; - - for (int i = 0; i < createdRoom.Playlist.Count; i++) - createdRoom.Playlist[i].ID = currentPlaylistItemId++; - - Rooms.Add(createdRoom); - createRoomRequest.TriggerSuccess(createdRoom); - return true; - - case JoinRoomRequest joinRoomRequest: - joinRoomRequest.TriggerSuccess(); - return true; - - case PartRoomRequest partRoomRequest: - partRoomRequest.TriggerSuccess(); - return true; - - case GetRoomsRequest getRoomsRequest: - var roomsWithoutParticipants = new List(); - - foreach (var r in Rooms) - { - var newRoom = new Room(); - - newRoom.CopyFrom(r); - newRoom.RecentParticipants.Clear(); - - roomsWithoutParticipants.Add(newRoom); - } - - getRoomsRequest.TriggerSuccess(roomsWithoutParticipants); - return true; - - case GetRoomRequest getRoomRequest: - getRoomRequest.TriggerSuccess(Rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId)); - return true; - - case GetBeatmapSetRequest getBeatmapSetRequest: - var onlineReq = new GetBeatmapSetRequest(getBeatmapSetRequest.ID, getBeatmapSetRequest.Type); - onlineReq.Success += res => getBeatmapSetRequest.TriggerSuccess(res); - onlineReq.Failure += e => getBeatmapSetRequest.TriggerFailure(e); - - // Get the online API from the game's dependencies. - game.Dependencies.Get().Queue(onlineReq); - return true; - - case CreateRoomScoreRequest createRoomScoreRequest: - createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); - return true; - - case SubmitRoomScoreRequest submitRoomScoreRequest: - submitRoomScoreRequest.TriggerSuccess(new MultiplayerScore - { - ID = currentScoreId++, - Accuracy = 1, - EndedAt = DateTimeOffset.Now, - Passed = true, - Rank = ScoreRank.S, - MaxCombo = 1000, - TotalScore = 1000000, - User = api.LocalUser.Value, - Statistics = new Dictionary() - }); - return true; - } - - return false; - }; - } - - public new void ClearRooms() => base.ClearRooms(); - - public new void Schedule(Action action) => base.Schedule(action); - } -} diff --git a/osu.Game/Tests/Visual/Multiplayer/TestRequestHandlingMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestRequestHandlingMultiplayerRoomManager.cs new file mode 100644 index 0000000000..5de518990a --- /dev/null +++ b/osu.Game/Tests/Visual/Multiplayer/TestRequestHandlingMultiplayerRoomManager.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + /// + /// A for use in multiplayer test scenes, backed by a . + /// Should generally not be used by itself outside of a . + /// + public class TestRequestHandlingMultiplayerRoomManager : MultiplayerRoomManager + { + public IReadOnlyList ServerSideRooms => handler.ServerSideRooms; + + private readonly TestRoomRequestsHandler handler = new TestRoomRequestsHandler(); + + [BackgroundDependencyLoader] + private void load(IAPIProvider api, OsuGameBase game) + { + ((DummyAPIAccess)api).HandleRequest = request => handler.HandleRequest(request, api.LocalUser.Value, game); + } + + /// + /// Adds a room to a local "server-side" list that's returned when a is fired. + /// + /// The room. + public void AddServerSideRoom(Room room) => handler.AddServerSideRoom(room); + } +} diff --git a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs index 6e1e831d9b..71acefb158 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs @@ -4,7 +4,6 @@ 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 { @@ -23,11 +22,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// IRoomManager RoomManager { get; } - /// - /// The cached . - /// - Bindable Filter { get; } - /// /// The cached . /// diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index 997c910dd4..8716646074 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -9,7 +9,6 @@ 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 { @@ -20,7 +19,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay { 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; diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs index ddbbfe501b..defc971eef 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs @@ -7,8 +7,8 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay; -using osu.Game.Screens.OnlinePlay.Lounge.Components; namespace osu.Game.Tests.Visual.OnlinePlay { @@ -19,7 +19,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay { public Bindable SelectedRoom { get; } public IRoomManager RoomManager { get; } - public Bindable Filter { get; } public OngoingOperationTracker OngoingOperationTracker { get; } public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker { get; } @@ -35,7 +34,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay { SelectedRoom = new Bindable(); RoomManager = CreateRoomManager(); - Filter = new Bindable(new FilterCriteria()); OngoingOperationTracker = new OngoingOperationTracker(); AvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); @@ -43,9 +41,9 @@ namespace osu.Game.Tests.Visual.OnlinePlay CacheAs(SelectedRoom); CacheAs(RoomManager); - CacheAs(Filter); CacheAs(OngoingOperationTracker); CacheAs(AvailabilityTracker); + CacheAs(new OverlayColourProvider(OverlayColourScheme.Plum)); } public object Get(Type type) @@ -73,6 +71,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay drawableComponents.Add(drawable); } - protected virtual IRoomManager CreateRoomManager() => new BasicTestRoomManager(); + protected virtual IRoomManager CreateRoomManager() => new TestRequestHandlingRoomManager(); } } diff --git a/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRequestHandlingRoomManager.cs similarity index 55% rename from osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs rename to osu.Game/Tests/Visual/OnlinePlay/TestRequestHandlingRoomManager.cs index 813e617ac5..d88fd68b20 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRequestHandlingRoomManager.cs @@ -2,12 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; -using osu.Framework.Bindables; +using osu.Framework.Allocation; using osu.Game.Beatmaps; +using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Rulesets; -using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Users; @@ -16,46 +15,42 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// /// A very simple for use in online play test scenes. /// - public class BasicTestRoomManager : IRoomManager + public class TestRequestHandlingRoomManager : RoomManager { - public event Action RoomsUpdated + public Action JoinRoomRequested; + + private int currentRoomId; + + private readonly TestRoomRequestsHandler handler = new TestRoomRequestsHandler(); + + [BackgroundDependencyLoader] + private void load(IAPIProvider api, OsuGameBase game) { - add { } - remove { } + ((DummyAPIAccess)api).HandleRequest = request => handler.HandleRequest(request, api.LocalUser.Value, game); } - 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) + public override void JoinRoom(Room room, string password = null, 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); + JoinRoomRequested?.Invoke(room, password); + base.JoinRoom(room, password, onSuccess, onError); } - public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) => onSuccess?.Invoke(room); - - public void PartRoom() - { - } - - public void AddRooms(int count, RulesetInfo ruleset = null) + 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" } }, + RoomID = { Value = -currentRoomId }, + Name = { Value = $@"Room {currentRoomId}" }, + Host = { Value = new User { Username = @"Host" } }, EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) }, - Category = { Value = i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal } + Category = { Value = i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal }, }; + if (withPassword) + room.Password.Value = @"password"; + if (ruleset != null) { room.Playlist.Add(new PlaylistItem @@ -72,6 +67,8 @@ namespace osu.Game.Tests.Visual.OnlinePlay } CreateRoom(room); + + currentRoomId++; } } } diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs new file mode 100644 index 0000000000..7f975c9985 --- /dev/null +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -0,0 +1,147 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Game.Online.API; +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.Users; + +namespace osu.Game.Tests.Visual.OnlinePlay +{ + /// + /// Represents a handler which pretends to be a server, handling room retrieval and manipulation requests + /// and returning a roughly expected state, without the need for a server to be running. + /// + public class TestRoomRequestsHandler + { + public IReadOnlyList ServerSideRooms => serverSideRooms; + + private readonly List serverSideRooms = new List(); + + private int currentRoomId; + private int currentPlaylistItemId; + private int currentScoreId; + + /// + /// Handles an API request, while also updating the local state to match + /// how the server would eventually respond and update an . + /// + /// The API request to handle. + /// The local user to store in responses where required. + /// The game base for cases where actual online requests need to be sent. + /// Whether the request was successfully handled. + public bool HandleRequest(APIRequest request, User localUser, OsuGameBase game) + { + switch (request) + { + case CreateRoomRequest createRoomRequest: + var apiRoom = new Room(); + + apiRoom.CopyFrom(createRoomRequest.Room); + + // Passwords are explicitly not copied between rooms. + apiRoom.HasPassword.Value = !string.IsNullOrEmpty(createRoomRequest.Room.Password.Value); + apiRoom.Password.Value = createRoomRequest.Room.Password.Value; + + AddServerSideRoom(apiRoom); + + var responseRoom = new APICreatedRoom(); + responseRoom.CopyFrom(createResponseRoom(apiRoom, false)); + + createRoomRequest.TriggerSuccess(responseRoom); + return true; + + case JoinRoomRequest joinRoomRequest: + { + var room = ServerSideRooms.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(); + return true; + + case GetRoomsRequest getRoomsRequest: + var roomsWithoutParticipants = new List(); + + foreach (var r in ServerSideRooms) + roomsWithoutParticipants.Add(createResponseRoom(r, false)); + + getRoomsRequest.TriggerSuccess(roomsWithoutParticipants); + return true; + + case GetRoomRequest getRoomRequest: + getRoomRequest.TriggerSuccess(createResponseRoom(ServerSideRooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId), true)); + return true; + + case GetBeatmapSetRequest getBeatmapSetRequest: + var onlineReq = new GetBeatmapSetRequest(getBeatmapSetRequest.ID, getBeatmapSetRequest.Type); + onlineReq.Success += res => getBeatmapSetRequest.TriggerSuccess(res); + onlineReq.Failure += e => getBeatmapSetRequest.TriggerFailure(e); + + // Get the online API from the game's dependencies. + game.Dependencies.Get().Queue(onlineReq); + return true; + + case CreateRoomScoreRequest createRoomScoreRequest: + createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); + return true; + + case SubmitRoomScoreRequest submitRoomScoreRequest: + submitRoomScoreRequest.TriggerSuccess(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.S, + MaxCombo = 1000, + TotalScore = 1000000, + User = localUser, + Statistics = new Dictionary() + }); + return true; + } + + return false; + } + + /// + /// Adds a room to a local "server-side" list that's returned when a is fired. + /// + /// The room. + public void AddServerSideRoom(Room room) + { + room.RoomID.Value ??= currentRoomId++; + for (int i = 0; i < room.Playlist.Count; i++) + room.Playlist[i].ID = currentPlaylistItemId++; + + serverSideRooms.Add(room); + } + + 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; + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs similarity index 69% rename from osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs rename to osu.Game/Tests/Visual/OsuGameTestScene.cs index f9a991f756..6a11bd3fea 100644 --- a/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Platform; using osu.Framework.Screens; @@ -23,15 +24,13 @@ using osu.Game.Screens.Menu; using osuTK.Graphics; using IntroSequence = osu.Game.Configuration.IntroSequence; -namespace osu.Game.Tests.Visual.Navigation +namespace osu.Game.Tests.Visual { /// /// A scene which tests full game flow. /// public abstract class OsuGameTestScene : OsuManualInputManagerTestScene { - private GameHost host; - protected TestOsuGame Game; protected override bool UseFreshStoragePerRun => true; @@ -39,10 +38,8 @@ namespace osu.Game.Tests.Visual.Navigation protected override bool CreateNestedActionContainer => false; [BackgroundDependencyLoader] - private void load(GameHost host) + private void load() { - this.host = host; - Child = new Box { RelativeSizeAxes = Axes.Both, @@ -55,13 +52,13 @@ namespace osu.Game.Tests.Visual.Navigation { AddStep("Create new game instance", () => { - if (Game != null) + if (Game?.Parent != null) { Remove(Game); Game.Dispose(); } - RecycleLocalStorage(); + RecycleLocalStorage(false); CreateGame(); }); @@ -72,29 +69,42 @@ namespace osu.Game.Tests.Visual.Navigation ConfirmAtMainMenu(); } + [TearDownSteps] + public void TearDownSteps() + { + AddStep("exit game", () => Game.Exit()); + AddUntilStep("wait for game exit", () => Game.Parent == null); + } + protected void CreateGame() { - Game = new TestOsuGame(LocalStorage, API); - Game.SetHost(host); - - // todo: this can be removed once we can run audio tracks without a device present - // see https://github.com/ppy/osu/issues/1302 - Game.LocalConfig.SetValue(OsuSetting.IntroSequence, IntroSequence.Circles); - - Add(Game); + AddGame(Game = CreateTestGame()); } + protected virtual TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API); + protected void PushAndConfirm(Func newScreen) { Screen screen = null; - AddStep("Push new screen", () => Game.ScreenStack.Push(screen = newScreen())); - AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen == screen && screen.IsLoaded); + IScreen previousScreen = null; + + AddStep("Push new screen", () => + { + previousScreen = Game.ScreenStack.CurrentScreen; + Game.ScreenStack.Push(screen = newScreen()); + }); + + AddUntilStep("Wait for new screen", () => screen.IsLoaded + && Game.ScreenStack.CurrentScreen != previousScreen + && previousScreen.GetChildScreen() == screen); } protected void ConfirmAtMainMenu() => AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu menu && menu.IsLoaded); public class TestOsuGame : OsuGame { + public new const float SIDE_OVERLAY_OFFSET_RATIO = OsuGame.SIDE_OVERLAY_OFFSET_RATIO; + public new ScreenStack ScreenStack => base.ScreenStack; public new BackButton BackButton => base.BackButton; @@ -103,7 +113,11 @@ namespace osu.Game.Tests.Visual.Navigation public new ScoreManager ScoreManager => base.ScoreManager; - public new SettingsPanel Settings => base.Settings; + public new Container ScreenOffsetContainer => base.ScreenOffsetContainer; + + public new SettingsOverlay Settings => base.Settings; + + public new NotificationOverlay Notifications => base.Notifications; public new MusicController MusicController => base.MusicController; @@ -115,14 +129,16 @@ namespace osu.Game.Tests.Visual.Navigation public new Bindable> SelectedMods => base.SelectedMods; - // if we don't do this, when running under nUnit the version that gets populated is that of nUnit. + // if we don't apply these changes, when running under nUnit the version that gets populated is that of nUnit. + public override Version AssemblyVersion => new Version(0, 0); public override string Version => "test game"; protected override Loader CreateLoader() => new TestLoader(); public new void PerformFromScreen(Action action, IEnumerable validScreens = null) => base.PerformFromScreen(action, validScreens); - public TestOsuGame(Storage storage, IAPIProvider api) + public TestOsuGame(Storage storage, IAPIProvider api, string[] args = null) + : base(args) { Storage = storage; API = api; @@ -131,6 +147,9 @@ namespace osu.Game.Tests.Visual.Navigation protected override void LoadComplete() { base.LoadComplete(); + + LocalConfig.SetValue(OsuSetting.IntroSequence, IntroSequence.Circles); + API.Login("Rhythm Champion", "osu!"); Dependencies.Get().SetValue(Static.MutedAudioNotificationShownOnce, true); diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 01dd7a25c8..752794d25a 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -34,9 +34,12 @@ 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 = 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 98aad821ce..03434961ea 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -7,7 +7,6 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; @@ -100,7 +99,7 @@ namespace osu.Game.Tests.Visual return factory; }); - RecycleLocalStorage(); + RecycleLocalStorage(false); var baseDependencies = base.CreateChildDependencies(parent); @@ -140,7 +139,7 @@ namespace osu.Game.Tests.Visual protected virtual bool UseFreshStoragePerRun => false; - public virtual void RecycleLocalStorage() + public virtual void RecycleLocalStorage(bool isDisposing) { if (localStorage?.IsValueCreated == true) { @@ -155,7 +154,7 @@ namespace osu.Game.Tests.Visual } localStorage = - new Lazy(() => isolatedHostStorage ?? new NativeStorage(Path.Combine(RuntimeInfo.StartupDirectory, $"{GetType().Name}-{Guid.NewGuid()}"))); + new Lazy(() => isolatedHostStorage ?? new TemporaryNativeStorage($"{GetType().Name}-{Guid.NewGuid()}")); } [Resolved] @@ -176,7 +175,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); @@ -199,7 +198,7 @@ namespace osu.Game.Tests.Visual if (contextFactory?.IsValueCreated == true) contextFactory.Value.ResetDatabase(); - RecycleLocalStorage(); + RecycleLocalStorage(true); } protected override ITestSceneTestRunner CreateRunner() => new OsuTestSceneTestRunner(); @@ -368,6 +367,11 @@ namespace osu.Game.Tests.Visual Add(runner = new TestSceneTestRunner.TestRunner()); } + protected override void InitialiseFonts() + { + // skip fonts load as it's not required for testing purposes. + } + public void RunTestBlocking(TestScene test) => runner.RunTestBlocking(test); } } diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index 2dc77fa72a..b2f5b1754f 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -5,6 +5,7 @@ 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.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -17,17 +18,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()))); - } - - [BackgroundDependencyLoader] - private void load() - { - Beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize = 2; + base.Content.Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -36,12 +31,19 @@ namespace osu.Game.Tests.Visual dependencies.CacheAs(new EditorClock()); - var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + var playable = GetPlayableBeatmap(); dependencies.CacheAs(new EditorBeatmap(playable)); return dependencies; } + protected virtual IBeatmap GetPlayableBeatmap() + { + var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + playable.Difficulty.CircleSize = 2; + return playable; + } + protected override void LoadComplete() { base.LoadComplete(); @@ -63,9 +65,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 +78,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..b34f7e2d5f 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); @@ -65,7 +67,7 @@ namespace osu.Game.Tests.Visual if (!AllowFail) { - var noFailMod = ruleset.GetAllMods().FirstOrDefault(m => m is ModNoFail); + var noFailMod = ruleset.CreateMod(); if (noFailMod != null) SelectedMods.Value = new[] { noFailMod }; } diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index b30be05ac4..aa46b516bf 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Framework.Testing; using osu.Game.Overlays; using osu.Game.Screens; @@ -32,6 +33,9 @@ namespace osu.Game.Tests.Visual content = new Container { RelativeSizeAxes = Axes.Both }, DialogOverlay = new DialogOverlay() }); + + Stack.ScreenPushed += (lastScreen, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed → {newScreen}"); + Stack.ScreenExited += (lastScreen, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed ← {newScreen}"); } protected void LoadScreen(OsuScreen screen) => Stack.Push(screen); 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/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index 5e5f20b307..d68984b144 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual if (autoplayMod != null) { - DrawableRuleset?.SetReplayScore(autoplayMod.CreateReplayScore(GameplayBeatmap.PlayableBeatmap, Mods.Value)); + DrawableRuleset?.SetReplayScore(autoplayMod.CreateReplayScore(GameplayState.Beatmap, Mods.Value)); return; } diff --git a/osu.Game/Tests/Visual/TestUserLookupCache.cs b/osu.Game/Tests/Visual/TestUserLookupCache.cs index d2941b5bd5..b73e81d0dd 100644 --- a/osu.Game/Tests/Visual/TestUserLookupCache.cs +++ b/osu.Game/Tests/Visual/TestUserLookupCache.cs @@ -10,10 +10,22 @@ namespace osu.Game.Tests.Visual { public class TestUserLookupCache : UserLookupCache { - protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User + /// + /// A special user ID which would return a for. + /// As a simulation to what a regular would return in the case of failing to fetch the user. + /// + public const int NULL_USER_ID = -1; + + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) { - Id = lookup, - Username = $"User {lookup}" - }); + if (lookup == NULL_USER_ID) + return Task.FromResult((User)null); + + return 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/Users/Drawables/ClickableAvatar.cs b/osu.Game/Users/Drawables/ClickableAvatar.cs index f73489ac61..c8af8d80e4 100644 --- a/osu.Game/Users/Drawables/ClickableAvatar.cs +++ b/osu.Game/Users/Drawables/ClickableAvatar.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Users.Drawables { @@ -71,6 +72,11 @@ namespace osu.Game.Users.Drawables { private LocalisableString tooltip = default_tooltip_text; + public ClickableArea() + : base(HoverSampleSet.Submit) + { + } + public override LocalisableString TooltipText { get => Enabled.Value ? tooltip : default; diff --git a/osu.Game/Users/Drawables/UpdateableAvatar.cs b/osu.Game/Users/Drawables/UpdateableAvatar.cs index df724404e9..6d48104131 100644 --- a/osu.Game/Users/Drawables/UpdateableAvatar.cs +++ b/osu.Game/Users/Drawables/UpdateableAvatar.cs @@ -44,7 +44,7 @@ namespace osu.Game.Users.Drawables protected override double LoadDelay => 200; - private readonly bool openOnClick; + private readonly bool isInteractive; private readonly bool showUsernameTooltip; private readonly bool showGuestOnNull; @@ -52,12 +52,12 @@ namespace osu.Game.Users.Drawables /// Construct a new UpdateableAvatar. /// /// 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. + /// If set to true, hover/click sounds will play and clicking the avatar will open the user's profile. + /// Whether to show the username rather than "view profile" on the tooltip. (note: this only applies if is also true) /// 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) + public UpdateableAvatar(User user = null, bool isInteractive = true, bool showUsernameTooltip = false, bool showGuestOnNull = true) { - this.openOnClick = openOnClick; + this.isInteractive = isInteractive; this.showUsernameTooltip = showUsernameTooltip; this.showGuestOnNull = showGuestOnNull; @@ -69,14 +69,22 @@ namespace osu.Game.Users.Drawables if (user == null && !showGuestOnNull) return null; - var avatar = new ClickableAvatar(user) + if (isInteractive) { - OpenOnClick = openOnClick, - ShowUsernameTooltip = showUsernameTooltip, - RelativeSizeAxes = Axes.Both, - }; - - return avatar; + return new ClickableAvatar(user) + { + OpenOnClick = true, + ShowUsernameTooltip = showUsernameTooltip, + RelativeSizeAxes = Axes.Both, + }; + } + else + { + return new DrawableAvatar(user) + { + RelativeSizeAxes = Axes.Both, + }; + } } } } diff --git a/osu.Game/Users/Drawables/UpdateableFlag.cs b/osu.Game/Users/Drawables/UpdateableFlag.cs index 1d30720889..7db834bf83 100644 --- a/osu.Game/Users/Drawables/UpdateableFlag.cs +++ b/osu.Game/Users/Drawables/UpdateableFlag.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; namespace osu.Game.Users.Drawables @@ -32,9 +33,17 @@ namespace osu.Game.Users.Drawables if (country == null && !ShowPlaceholderOnNull) return null; - return new DrawableFlag(country) + return new Container { RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new DrawableFlag(country) + { + RelativeSizeAxes = Axes.Both + }, + new HoverClickSounds(HoverSampleSet.Submit) + } }; } diff --git a/osu.Game/Users/User.cs b/osu.Game/Users/User.cs index 2e04693e82..20c23153f0 100644 --- a/osu.Game/Users/User.cs +++ b/osu.Game/Users/User.cs @@ -138,11 +138,11 @@ namespace osu.Game.Users [JsonProperty(@"loved_beatmapset_count")] public int LovedBeatmapsetCount; - [JsonProperty(@"ranked_and_approved_beatmapset_count")] - public int RankedAndApprovedBeatmapsetCount; + [JsonProperty(@"ranked_beatmapset_count")] + public int RankedBeatmapsetCount; - [JsonProperty(@"unranked_beatmapset_count")] - public int UnrankedBeatmapsetCount; + [JsonProperty(@"pending_beatmapset_count")] + public int PendingBeatmapsetCount; [JsonProperty(@"scores_best_count")] public int ScoresBestCount; diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index f633773d11..91bcb37fcc 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -25,38 +25,59 @@ namespace osu.Game.Users public override string Status => "Choosing a beatmap"; } - public class MultiplayerGame : UserActivity + public abstract class InGame : UserActivity { - public override string Status => "Playing with others"; - } - - public class Editing : UserActivity - { - public BeatmapInfo Beatmap { get; } - - public Editing(BeatmapInfo info) - { - Beatmap = info; - } - - public override string Status => @"Editing a beatmap"; - } - - public class SoloGame : UserActivity - { - public BeatmapInfo Beatmap { get; } + public BeatmapInfo BeatmapInfo { get; } public RulesetInfo Ruleset { get; } - public SoloGame(BeatmapInfo info, RulesetInfo ruleset) + protected InGame(BeatmapInfo beatmapInfo, RulesetInfo ruleset) { - Beatmap = info; + BeatmapInfo = beatmapInfo; Ruleset = ruleset; } public override string Status => Ruleset.CreateInstance().PlayingVerb; } + public class InMultiplayerGame : InGame + { + public InMultiplayerGame(BeatmapInfo beatmapInfo, RulesetInfo ruleset) + : base(beatmapInfo, ruleset) + { + } + + public override string Status => $@"{base.Status} with others"; + } + + public class InPlaylistGame : InGame + { + public InPlaylistGame(BeatmapInfo beatmapInfo, RulesetInfo ruleset) + : base(beatmapInfo, ruleset) + { + } + } + + public class InSoloGame : InGame + { + public InSoloGame(BeatmapInfo beatmapInfo, RulesetInfo ruleset) + : base(beatmapInfo, ruleset) + { + } + } + + public class Editing : UserActivity + { + public BeatmapInfo BeatmapInfo { get; } + + public Editing(BeatmapInfo info) + { + BeatmapInfo = info; + } + + public override string Status => @"Editing a beatmap"; + } + public class Spectating : UserActivity { public override string Status => @"Spectating a game"; @@ -69,7 +90,7 @@ namespace osu.Game.Users public class InLobby : UserActivity { - public override string Status => @"In a multiplayer lobby"; + public override string Status => @"In a lobby"; public readonly Room Room; diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 0981136dba..ff0d03a036 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -31,6 +31,7 @@ namespace osu.Game.Users protected Drawable Background { get; private set; } protected UserPanel(User user) + : base(HoverSampleSet.Submit) { if (user == null) throw new ArgumentNullException(nameof(user)); diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 5ddcd86d28..449b0aa212 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -3,6 +3,7 @@ using System; using Newtonsoft.Json; +using osu.Framework.Localisation; using osu.Game.Scoring; using osu.Game.Utils; using static osu.Game.Users.User; @@ -45,7 +46,7 @@ namespace osu.Game.Users public double Accuracy; [JsonIgnore] - public string DisplayAccuracy => (Accuracy / 100).FormatAccuracy(); + public LocalisableString DisplayAccuracy => (Accuracy / 100).FormatAccuracy(); [JsonProperty(@"play_count")] public int PlayCount; diff --git a/osu.Game/Utils/ColourUtils.cs b/osu.Game/Utils/ColourUtils.cs new file mode 100644 index 0000000000..515963971d --- /dev/null +++ b/osu.Game/Utils/ColourUtils.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Utils; +using osuTK.Graphics; + +namespace osu.Game.Utils +{ + public static class ColourUtils + { + /// + /// Samples from a given linear gradient at a certain specified point. + /// + /// The gradient, defining the colour stops and their positions (in [0-1] range) in the gradient. + /// The point to sample the colour at. + /// A sampled from the linear gradient. + public static Color4 SampleFromLinearGradient(IReadOnlyList<(float position, Color4 colour)> gradient, float point) + { + if (point < gradient[0].position) + return gradient[0].colour; + + for (int i = 0; i < gradient.Count - 1; i++) + { + var startStop = gradient[i]; + var endStop = gradient[i + 1]; + + if (point >= endStop.position) + continue; + + return Interpolation.ValueAt(point, startStop.colour, endStop.colour, startStop.position, endStop.position); + } + + return gradient[^1].colour; + } + } +} diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index df1b6cf00d..d14dbb49f3 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -2,8 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Globalization; using Humanizer; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; namespace osu.Game.Utils { @@ -13,9 +14,8 @@ namespace osu.Game.Utils /// Turns the provided accuracy into a percentage with 2 decimal places. /// /// The accuracy to be formatted. - /// An optional format provider. /// formatted accuracy in percentage - public static string FormatAccuracy(this double accuracy, IFormatProvider formatProvider = null) + public static LocalisableString FormatAccuracy(this double accuracy) { // for the sake of display purposes, we don't want to show a user a "rounded up" percentage to the next whole number. // ie. a score which gets 89.99999% shouldn't ever show as 90%. @@ -23,7 +23,7 @@ namespace osu.Game.Utils // percentile with a non-matching grade is confusing. accuracy = Math.Floor(accuracy * 10000) / 10000; - return accuracy.ToString("0.00%", formatProvider ?? CultureInfo.CurrentCulture); + return accuracy.ToLocalisableString("0.00%"); } /// 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/LegacyUtils.cs b/osu.Game/Utils/LegacyUtils.cs new file mode 100644 index 0000000000..64306adf50 --- /dev/null +++ b/osu.Game/Utils/LegacyUtils.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osuTK.Graphics; + +namespace osu.Game.Utils +{ + public static class LegacyUtils + { + public static Color4 InterpolateNonLinear(double time, Color4 startColour, Color4 endColour, double startTime, double endTime, Easing easing = Easing.None) + => InterpolateNonLinear(time, startColour, endColour, startTime, endTime, new DefaultEasingFunction(easing)); + + public static Colour4 InterpolateNonLinear(double time, Colour4 startColour, Colour4 endColour, double startTime, double endTime, Easing easing = Easing.None) + => InterpolateNonLinear(time, startColour, endColour, startTime, endTime, new DefaultEasingFunction(easing)); + + /// + /// Interpolates between two sRGB s directly in sRGB space. + /// + public static Color4 InterpolateNonLinear(double time, Color4 startColour, Color4 endColour, double startTime, double endTime, TEasing easing) where TEasing : IEasingFunction + { + if (startColour == endColour) + return startColour; + + double current = time - startTime; + double duration = endTime - startTime; + + if (duration == 0 || current == 0) + return startColour; + + float t = Math.Max(0, Math.Min(1, (float)easing.ApplyEasing(current / duration))); + + return new Color4( + startColour.R + t * (endColour.R - startColour.R), + startColour.G + t * (endColour.G - startColour.G), + startColour.B + t * (endColour.B - startColour.B), + startColour.A + t * (endColour.A - startColour.A)); + } + + /// + /// Interpolates between two sRGB s directly in sRGB space. + /// + public static Colour4 InterpolateNonLinear(double time, Colour4 startColour, Colour4 endColour, double startTime, double endTime, TEasing easing) where TEasing : IEasingFunction + { + if (startColour == endColour) + return startColour; + + double current = time - startTime; + double duration = endTime - startTime; + + if (duration == 0 || current == 0) + return startColour; + + float t = Math.Max(0, Math.Min(1, (float)easing.ApplyEasing(current / duration))); + + return new Colour4( + startColour.R + t * (endColour.R - startColour.R), + startColour.G + t * (endColour.G - startColour.G), + startColour.B + t * (endColour.B - startColour.B), + startColour.A + t * (endColour.A - startColour.A)); + } + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index d80d2ade82..9ee6f4cf52 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -20,26 +20,26 @@ - + - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + diff --git a/osu.iOS.props b/osu.iOS.props index e339e49187..110de79285 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -93,12 +93,12 @@ - + - - + + diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index 1cbe4422cc..1203c3659b 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -117,7 +117,7 @@ - + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index d2c5b1223c..e42b30e944 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -19,8 +19,8 @@ HINT DO_NOT_SHOW WARNING - WARNING - WARNING + HINT + HINT WARNING WARNING WARNING @@ -105,8 +105,9 @@ HINT HINT WARNING + DO_NOT_SHOW + DO_NOT_SHOW WARNING - DO_NOT_SHOW WARNING WARNING WARNING @@ -130,7 +131,7 @@ HINT WARNING WARNING - HINT + WARNING WARNING WARNING WARNING @@ -304,6 +305,7 @@ AABB API BPM + FPS GC GL GLSL